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
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 current landscape restricts our ability to express our domain, our ontology, in a succinct and intuitive way.
- We cannot natively apply adjectives to our nouns (e.g. Positive number.)
- We cannot natively combine our adjectives to form new ones (e.g. Positive AND even number.)
- 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]
- ...
Adjective.^ solved these problems, such that:
- You can create arbitrary restrictions on base types (a.k.a. adjectives in linguistics.)
- You can use Boolean Algebra to arbitrarily create new adjectives from existing ones at runtime.
- The range of valid values, the semantics and intent are forever captured in the
Adjective
. - 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" 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"))
}
- 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 createAdjective
came in part fromrefined
, howeverAdjective
's angle is slightly different, in that it foregoes the ability of compile-time refinement in favor of usability and simplicity.