In this post, we will demonstrate a technique called type refinement (Aux pattern) that was covered in a Spartan session by John De Goes.
We would like to solve the following problem: Given a Member
and a Family
, we would like to make sure a Selection
is valid at compile-time. A Selection
is valid only if the Member
provided belongs to the Family
passed in parameter.
case class Member(name: String)
sealed trait Family
case class Selection(family: Family, member: Member)
A first approach consists in providing a type parameter:
case class Member[A](name: String)
sealed trait Family[A]
case class Selection[A](family: Family[A], member: Member[A])
This would work to a certain extent but the compiler could still be cheated by providing a Member
having the same type than a Family
while not belonging to it:
trait SomeType
val lennon: Family[SomeType] = new Family[SomeType] {}
val mccartney: Family[SomeType] = new Family[SomeType] {}
val john = Member[SomeType]("John")
val paul = Member[SomeType]("Paul")
Selection(lennon, john) // compiles
Selection(lennon, paul) // compiles
This tells us that a Family
's type should not be exposed. Let’s use a type member to hide this information:
case class Member[A](name: String)
sealed trait Family {
type Tag
}
object Family {
def mk: Family = new Family {}
}
Tag
is a type member and therefore unique to each Family
instance. Secondly, as Family
is sealed, it cannot be instantiated from outside the file where it is defined, hence the smart constructor Family.mk
. This prevents a user from defining a Tag
that could be used for more than one Family
.
Notice also that a value for Tag
does not need to be provided when instantiating a Family
. We are not done however, as the question is now how to ensure the selection is valid:
case class Selection[A](family: Family, member: Member[???])
Ideally, we would like to provide the Member
type constructor with the Tag
of the Family
passed in argument:
case class Selection[A](
family: Family,
member: Member[family.Tag]
) // does not compile
This approach, unfortunately, does not work but there is a workaround known as the Aux
pattern. One way to think about this pattern is to see Aux
as a getter for some type information encapsulated in the Family
type.
case class Member[A](name: String)
sealed trait Family {
type Tag
}
object Family {
type Aux[A] = Family { type Tag = A }
def mk: Family = new Family {}
}
case class Selection[A](family: Family.Aux[A], member: Member[A])
This now guarantees that Family#Tag
is equal to A
:
val (f1, f2) = (Family.mk, Family.mk)
val john = Member[f1.Tag]("John")
val paul = Member[f2.Tag]("Paul")
Selection(f1, john) // compiles
Selection(f1, paul) // does not compile
The Aux
pattern is a great tool whenever you want to add type constraints while not exposing some internal aspects of DSL. In a future post, we will cover a concrete use case relying on it.