Skip to content

Instantly share code, notes, and snippets.

@manjuraj
Last active July 22, 2020 14:09
Show Gist options
  • Save manjuraj/21330b3abd2465fe3ce9 to your computer and use it in GitHub Desktop.
Save manjuraj/21330b3abd2465fe3ce9 to your computer and use it in GitHub Desktop.
typesafe builders in scala
//
// References:
// - http://www.tikalk.com/java/type-safe-builder-scala-using-type-constraints/
// - http://www.blumenfeld-maso.com/2011/05/statically-controlling-calls-to-methods-in-scala/
// - http://dcsobral.blogspot.com/2009/09/type-safe-builder-pattern.html
// - http://blog.rafaelferreira.net/2008/07/type-safe-builder-pattern-in-scala.html
// - http://jim-mcbeath.blogspot.com/2009/09/type-safe-builder-in-scala-part-4.html
// - http://villane.wordpress.com/2010/03/05/taking-advantage-of-scala-2-8-replacing-the-builder/
// - http://debasishg.blogspot.com/2010/08/using-generalized-type-constraints-how.html#sthash.GKfUGq9p.dpuf
//
//
// Typesafe builder using implicit parameters and equality type constraint =:=
//
class Builder private (i: Int) {
def this() = this(-1)
def withProperty(i: Int) = new Builder(i)
def build() = println(i)
}
scala> new Builder().build // withProperty not called (runtime error)
-1
scala> new Builder().withProperty(1).withProperty(2).build // withProperty called twice (runtime error)
2
// Tell the compiler whether the builder is complete or not by encoding
// this info as a type as use this encoding in the generic type of the
// builder class
sealed trait TBoolean
sealed trait TTrue extends TBoolean
sealed trait TFalse extends TBoolean
class Builder[HasProperty <: TBoolean] private(i: Int) {
def this() = this(-1)
def withProperty(i: Int) = new Builder[TTrue](i)
def build() = println(i)
}
object Builder {
def apply() = new Builder[TFalse]()
}
// With this repr:
// - complete builder is of type Builder[TTrue]
// - incomplete builder is of type Builder[TFalse]
// To make it typesafe, we want the call to build to be compiled
// only if invoked on Builder[TTrue]. We do this by using generic
// type constraints and implicit parameters
// We can achieve this through an implicit parameter that acts as
// evidence that HasProperty is TTrue. If HasProperty is TTrue,
// there is an instance that the compiler can find and so call
// to build is compile time safe. If HasProperty is TFalse, then
// there is no such instance and the compiler will issue a
// warning
// A =:= B defines that type A and type B are one and the same
sealed abstract class =:=[From, To] extends (From => To)
object =:= {
implicit def tpEquals[A]: A =:= A = new (A =:= A) {
def apply(x: A) = x
}
}
scala> implicitly[Int =:= Int]
res2: =:=[Int,Int] = <function1>
scala> implicitly[Int =:= Double]
<console>:8: error: Cannot prove that Int =:= Double.
implicitly[Int =:= Double]
// So, with the =:= as a mechansim to enforce type constraints, we can
// add appropriate type constraints to withProperty() and build() method
// so that they only work on right instances of builder
class Builder[HasProperty <: TBoolean] private(i: Int) {
def this() = this(-1)
def withProperty(i: Int)(implicit ev: HasProperty =:= TFalse) = new Builder[TTrue](i)
def build()(implicit ev: HasProperty =:= TTrue) = println(i)
}
object Builder {
def apply() = new Builder[TFalse]
}
scala> Builder().build
<console>:13: error: Cannot prove that TFalse =:= TTrue.
Builder().build
^
scala> Builder().withProperty(2).withProperty(3)
<console>:13: error: Cannot prove that TTrue =:= TFalse.
Builder().withProperty(2).withProperty(3)
^
scala> Builder().withProperty(2).build()
2
scala> Builder().withProperty(2).build
2
//
// Control statically how many times methods are called in an API using a combination of:
// - Phantom Types, and
// - Generic Type Constraints
//
//
// Domain: scotch builder
// - brand of shiskey
// - how it should be prepared
// - kind of glass
// - brand
//
sealed trait Preparation
case object Neat extends Preparation
case object OnTheRocks extends Preparation
case object WithWater extends Preparation
sealed trait Glass
case object Short extends Glass
case object Tall extends Glass
case object Tulip extends Glass
case class OrderOfScotch(brand: String, mode: Preparation, isDouble: Boolean, val glass: Option[Glass])
case class ScotchBuilder(
brand: Option[String] = None,
mode: Option[Preparation] = None,
doubleStatus: Option[Boolean] = None,
glass: Option[Glass] = None) {
def withBrand(b: String) = copy(brand = Some(b))
def withMode(p: Preparation) = copy(mode = Some(p))
def isDouble(b: Boolean) = copy(doubleStatus = Some(b))
def withGlass(g: Glass) = copy(glass = Some(g))
def build() = new OrderOfScotch(brand.get, mode.get, doubleStatus.get, glass);
}
//
// Issues with above builder:
// - client can re-invoke the same setter methods over and over again
// - client can also completely forget to call other methods that should be called
//
//
// Solution: use Phantom Types and Generic Type constraints to ensure at
// compile time only certain Builder methods are invoked with:
// - at-most-once semantics. E.g., withGlass should be called zero or one time
// by client code for a single ScotchBuilder instance
// - exactly-once semantics. E.g., withBrand, withMode, and isDouble each need
// to be called exactly once
// - one-or-more-times semantics
//
// The above technique are not just limited to Builer APIs. Pretty much any API
// where you wanted to constrain the call semantics can employ Phantom Types
// and Generic Type constraints to achieve the desired call semantics. This is
// going to apply to objects that traditionally walk through a life cycle. For
// example, any API where there are init() and destroy(). The Builder under
// construction also has a lifecycle: several configuration methods must be
// called, and then final the build method gets invoked
//
// Here, the build() method is uncallable except when the Builder is fully
// configured. ScotchBuilder class takes one type parameter per method
// whose calls we want to track. The type parameters are going to track
// whether each of the with() methods have been called or not; the Phantom
// Types - Zero and Once are defined to represent these two states. The
// build() method is only callable if the appropriate type parameters
// are bound to Once Type
//
// So we add 4 type parameters, each able to be bound to the type
// Zero or Once. So instead of having 1 ScotchBuilder class, we actually
// are defining 16. That is, 16 different permutations of the possible
// bindings to the 4 type parameters. The build method will then be
// constraining to be callable on ScotchBuilder[Once, Once, Once, _]
// (one of 2 specific bindings).
//
sealed trait Count
sealed trait Zero extends Count
sealed trait Once extends Count
object ScotchBuilder {
def apply() = new ScotchBuilder[Zero, Zero, Zero, Zero]()
}
case class ScotchBuilder[WithBrandTracking <: Count, WithModeTracking <: Count, IsDoubleTracking <: Count, WithGlassTracking <: Count](
brand: Option[String] = None,
mode: Option[Preparation] = None,
doubleStatus: Option[Boolean] = None,
glass: Option[Glass] = None) {
def withBrand(b: String) = copy[Once, WithModeTracking, IsDoubleTracking, WithGlassTracking](brand = Some(b))
def withMode(p: Preparation) = copy[WithBrandTracking, Once, IsDoubleTracking, WithGlassTracking](mode = Some(p))
def isDouble(b: Boolean) = copy[WithBrandTracking, WithModeTracking, Once, WithGlassTracking](doubleStatus = Some(b))
def withGlass(g: Glass) = copy[WithBrandTracking, WithModeTracking, IsDoubleTracking, Once](glass = Some(g))
def build()(implicit evb: WithBrandTracking =:= Once, evm: WithModeTracking =:= One, evds: IsDoubleTracking =:= Once)= {
new OrderOfScotch(brand.get, mode.get, doubleStatus.get, glass)
}
}
//
// We use the =:= type class to guarantee this constraint on the
// ScotchBuilder type parameters. An implicit value of =:=[A, B] only
// exists when A == B. We further created a type alias
// IsOnce[T] = =:=[T, Once], which allows us to apply =:= as a
// type class and use context bound syntax
//
// The upshot of all of this is that any attempt to invoke build on a
// ScotchBuilder not matching ScotchBuilder[Once, Once, Once, _] simply
// cannot be compiled. You literally cannot compile code that improperly
// uses a ScotchBuilder to build a order of scotch!
//
// Note that in the code above we never actually create an instance
// of Zero or Once — these type parameter bindings are purely for
// compile-time bookkeeping. Hence the term Phantom Types, because
// these types are never instantiated nor participate at runtime
//
// We can even do a little better with the builder above by constraining
// the with() methods to have exactly-once or at-most-once call
// semantics, as appropriate. This is going to make the compiler fail
// at the point where the API is being misused — i.e., where a with()
// method is being used the second time for a single ScotchBuilkder
// instance. So it’ll be a lot easier when using this API to figure
// out what you did wrong
//
sealed trait Count
sealed trait Zero extends Count
sealed trait Once extends Count
object ScotchBuilder {
def apply() = new ScotchBuilder[Zero, Zero, Zero, Zero]()
}
case class ScotchBuilder[WithBrandTracking <: Count, WithModeTracking <: Count, IsDoubleTracking <: Count, WithGlassTracking <: Count](
brand: Option[String] = None,
mode: Option[Preparation] = None,
doubleStatus: Option[Boolean] = None,
glass: Option[Glass] = None) {
type IsOnce[T] = =:=[T, Once]
type IsZero[T] = =:=[T, Zero]
def withBrand[B <: WithBrandTracking : IsZero](b: String) = copy[Once, WithModeTracking, IsDoubleTracking, WithGlassTracking](brand = Some(b))
def withMode[M <: WithModeTracking : IsZero](p: Preparation) = copy[WithBrandTracking, Once, IsDoubleTracking, WithGlassTracking](mode = Some(p))
def isDouble[D <: IsDoubleTracking : IsZero](b: Boolean) = copy[WithBrandTracking, WithModeTracking, Once, WithGlassTracking](doubleStatus = Some(b))
def withGlass[G <: WithGlassTracking : IsZero](g: Glass) = copy[WithBrandTracking, WithModeTracking, IsDoubleTracking, Once](glass = Some(g))
def build[B <: WithBrandTracking : IsOnce, M <: WithModeTracking : IsOnce, D <: IsDoubleTracking : IsOnce]() = {
new OrderOfScotch(brand.get, mode.get, doubleStatus.get, glass)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment