Skip to content
Programming is an exercise in linguistics; spice-up Scala types with Adjective.
Branch: master
Clone or download
Latest commit 0ffd84e Mar 11, 2019
Permalink
Type Name Latest commit message Commit time
Failed to load latest commit information.
library/shared/src
project
.gitignore
LICENSE
README.md
build.sbt

README.md

Adjective.^

Programming is an exercise in linguistics; spice-up Scala types with Adjective

Sonatype Artifact

Currently builds for 2.12.x

val adjectiveVersion = "0.4"

// JVM
libraryDependencies += "com.victorivri" %% "adjective" % adjectiveVersion

// Scala.js
libraryDependencies += "com.victorivri" %%% "adjective" % adjectiveVersion

At a Glance

// First, we define the precise types that make up our domain/universe/ontology
object PersonOntology {
  // `Nuanced[T]` is the building block of our type algebra
  // Try to make them as atomic as possible
  case object DbId                extends Nuanced[Int]    ((id)=> 0 <= id && id < 2000000)
  case object NameSequence        extends Nuanced[String] (_.matches("^[A-Z][a-zA-Z]{1,31}$"))
  case object DisallowedSequences extends Nuanced[String] (_.toLowerCase.contains("fbomb"))
  case object ScottishLastName    extends Nuanced[String] (_ startsWith "Mc")
  case object JewishLastName      extends Nuanced[String] (_ endsWith "berg")

  // We use boolean algebra to combine base adjectives into more nuanced adjectives
  val LegalName = NameSequence & ~DisallowedSequences // `~X` negates `X`
  val FirstName = LegalName
  val SomeHeritageLastName = LegalName & (ScottishLastName <+> JewishLastName) // `<+>` stands for Xor, ⊕ is the math notation
}

import PersonOntology._

// Our Domain is now ready to be used in ADTs, validations and elsewhere.
// As opposed to monadic types, the preferred way to integrate
// Adjective is to use its "successful" type, conveniently accessible through `_.^`
case class Person (id: DbId.^, firstName: FirstName.^, lastName: SomeHeritageLastName.^)

The Problem

We should be able to think and express our domain in these terms, but currently, it is very cumbersome, so we mostly end up using the raw types, and create weak constraints via opaque ad-hoc validations.

This prevents us from having native expressive types, such as:

  • Natural numbers
  • All IPs in a net mask
  • Valid emails
  • Obtuse angles
  • Dates in the year 2525
  • ...

Encoding that domain knowledge into ad-hoc validation methods and smart constructors strips this information from the domain, often leaving developers confused about valid values, unwritten rules, semantics, and intent.

And even if we did encode that knowledge into custom classes using smart constructors, we are still missing the ability to natively perform algebra on those types, and derive new types from the basic ones.

For example:

  • Router rule range: NetMask1 OR NetMask2 AND NOT NetMask3
  • Internal email: Valid email address AND Company hostname OR Subsidiary hostname
  • Valid Names: Capitalized strings AND Strings of length 2 to 30 AND Strings comprised of only [a-zA-Z]
  • ...

To sum up:

The current landscape restricts our ability to express our domain, our ontology, in a succinct and intuitive way.

  1. We cannot natively apply adjectives to our nouns (e.g. Positive number.)
  2. We cannot natively combine our adjectives to form new ones (e.g. Positive AND even number.)
  3. We cannot easily maintain semantic information in our types without clunky, non-composable custom wrapper-types.

The Solution

Adjective.^ solved these problems, such that:

  1. You can create arbitrary restrictions on base types (a.k.a. refined types, or adjectives in linguistics.)
  2. You can use Boolean Algebra to arbitrarily create new adjectives from existing ones at runtime.
  3. The range of valid values, the semantics and intent are forever captured in the Adjective.
  4. It is (rather) lightweight:
    • Runtime operations are cacheable and predictable (TODO: benchmark).
    • Adjective rules are best stored as singletons to conserve memory footprint and allocation.
    • Minimum boilerplate.
    • Little knowledge of advanced Scala/Typelevel features required.
    • Zero library dependencies.

Usage Example

The following is a passing spec:

  "Usage example" in {

    // First, we define the precise types that make up our domain/universe/ontology
    object PersonOntology {
      // `Nuanced[T]` is the building block of our type algebra
      // Try to make them as atomic as possible
      case object DbId                extends Nuanced[Int]    ((id)=> 0 <= id && id < 2000000)
      case object NameSequence        extends Nuanced[String] (_.matches("^[A-Z][a-zA-Z]{1,31}$"))
      case object DisallowedSequences extends Nuanced[String] (_.toLowerCase.contains("fbomb"))
      case object ScottishLastName    extends Nuanced[String] (_ startsWith "Mc")
      case object JewishLastName      extends Nuanced[String] (_ endsWith "berg")

      // We use boolean algebra to combine base adjectives into more nuanced adjectives
      val LegalName = NameSequence & ~DisallowedSequences // `~X` negates `X`
      val FirstName = LegalName
      val SomeHeritageLastName = LegalName & (ScottishLastName <+> JewishLastName) // `<+>` stands for Xor, ⊕ is the math notation
    }

    import PersonOntology._
    import TildaFlow._ // so we can use the convenient ~ operator

    // Our Domain is now ready to be used in ADTs, validations and elsewhere.
    // As opposed to monadic types, the preferred way to integrate
    // Adjective is to use its "successful" type, conveniently accessible through `_.^`
    case class Person (id: DbId.^, firstName: FirstName.^, lastName: SomeHeritageLastName.^)

    // We test membership to an adjective using `mightDescribe`.
    // We string together the inputs, to form an easily-accessible data structure:
    // Either (list of failures, tuple of successes in order of evaluation)
    val validatedInput =
      (DbId                  mightDescribe 123) ~
      (FirstName             mightDescribe "Bilbo") ~
      (SomeHeritageLastName  mightDescribe "McBeggins")

    // The tupled form allows easy application to case classes
    val validPerson = validatedInput map Person.tupled

    // Best way to access is via Either methods or pattern match
    validPerson match {
      case Right(Person(id, firstName, lastName)) => // as you'd expect
      case _ => throw new RuntimeException()
    }

    // Trying to precisely type the Includes/Excludes exposes a
    // little bit of clunkiness in the path-dependent types of `val`s
    validPerson shouldBe Right(
      Person(
        Includes(DbId,123), // this works great because DbId is a type, not a `val`
        Includes(FirstName, "Bilbo").asInstanceOf[FirstName.^], // ouch!
        Includes(SomeHeritageLastName, "McBeggins").asInstanceOf[SomeHeritageLastName.^])) // one more ouch.

    // Using the `_.base` we can access the base types if/when we wish
    val baseTypes = validPerson map { person =>
      (person.id.base, person.firstName.base, person.lastName.base)
    }

    baseTypes shouldBe Right((123,"Bilbo","McBeggins"))

    // Using toString gives an intuitive peek at the rule algebra
    //
    // The atomic [Nuanced#toString] gets printed out.
    // Beware that both `equals` and `hashCode` are (mostly) delegated to the `toString` implementation
    validPerson.right.get.toString shouldBe
      "Person({ 123 ∈ DbId },{ Bilbo ∈ (NameSequence & ~DisallowedSequences) },{ McBeggins ∈ ((NameSequence & ~DisallowedSequences) & (ScottishLastName ⊕ JewishLastName)) })"

    // Applying an invalid set of inputs accumulates all rules that failed
    val invalid =
      (DbId                  mightDescribe -1) ~
      (FirstName             mightDescribe "Bilbo") ~
      (SomeHeritageLastName  mightDescribe "Ivanov") map Person.tupled

    // We can access the failures to belong to an adjective directly
    invalid shouldBe Left(List(Excludes(DbId,-1), Excludes(SomeHeritageLastName, "Ivanov")))

    // Slightly clunky, but we can translate exclusions to e.g. human-readable validation strings - or anything else
    // TODO Using tuple of exclusions as opposed to a List that disregards types would make it easier.
    val exclusionMappings =
      invalid.left.map { exclusions =>
        exclusions.map { y => y match {
            case Excludes(DbId, x)                 => s"Bad DB id $x"
            case Excludes(SomeHeritageLastName, x) => s"Bad Last Name $x"
          }
        }
      }

    exclusionMappings shouldBe Left(List("Bad DB id -1", "Bad Last Name Ivanov"))
  }

Literature Review

  1. This document would be incomplete without mentioning the excellent refined library. The goals of refined are very similar, yet the scope and methods are different. The motivation to create Adjective came in part from refined, however Adjective's angle is slightly different, in that it foregoes the ability of compile-time refinement in favor of usability and simplicity.
You can’t perform that action at this time.
You signed in with another tab or window. Reload to refresh your session. You signed out in another tab or window. Reload to refresh your session.