vivri / adjective   0.5.1

MIT License GitHub

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

Scala versions: 2.13 2.12
Scala.js versions: 1.x

Adjective.^

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

Build status Gitter chat

Sonatype Artifact

Currently builds for 2.12.x and 2.13.x

val adjectiveVersion = "0.5.0"

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

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

At a Glance

import com.victorivri.adjective.AdjectiveBase._

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

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.

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]
  • ...

The Solution

Adjective.^ solved these problems, such that:

  1. You can create arbitrary restrictions on base types (a.k.a. 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 (somewhat) 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 {
      // `Adjective[T]` is the building block of our type algebra
      // Try to make them as atomic as possible
      case object DbId                extends Adjective[Int]    ((id)=> 0 <= id && id < 2000000)
      case object NameSequence        extends Adjective[String] (_.matches("^[A-Z][a-zA-Z]{1,31}$"))
      case object DisallowedSequences extends Adjective[String] (_.toLowerCase.contains("fbomb"))
      case object ScottishLastName    extends Adjective[String] (_ startsWith "Mc")
      case object JewishLastName      extends Adjective[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
    // AdjectiveBase 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()
    }

    // we can use `map` to operate on the underlying type without breaking the flow
    validPerson map { _.id map (_ + 1) } shouldBe Right(DbId mightDescribe 124)

    // 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 [Adjective#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
    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.