Previously I discussed about the advantages of wrapping common types like
Int
or String
in a value class. This allows us to encode more semantic meaning into our types, and means we can use
the compiler to check for a number of bugs. In this post I’ll discuss how we can use the Invariant
type class to
selectively surface functionality from the underlying type for our value class.
Value classes and their desired behaviour
When we wrap a type in a value class, we lose all of the behaviour that comes with the underlying type. An Int
can be
summed, multiplied, ordered etc, but when we wrap an Int
in a value class, our new class has none of these behaviours.
For this post, we’re going to look at two value classes:
final case class CustomerId(asInt: Int) extends AnyVal
final case class NumOrders(asInt: Int) extends AnyVal
NumOrders
needs to be ordered so that we can check which of two NumOrders
values is larger. We should also be able
to sum two NumOrders
values together. CustomerId
also needs to be ordered. But summing two CustomerId
values is
nonsense, so this operation ideally would be impossible.
How should we achieve this?
The worst option: unwrap the value class
The most obvious option is to simply use the .asInt
field to unwrap our value classes whenever we want to manipulate
them. For example, to sort a list of CustomerId
s:
val customerIds: List[CustomerId] = List(
CustomerId(800412),
CustomerId(285080),
CustomerId(995412),
)
val sortedIds: List[CustomerId] = customerIds.sortBy(_.asInt)
Or to find the sum of a list of NumOrders
:
val numOrdersList: List[NumOrders] = List(
NumOrders(5),
NumOrders(10),
NumOrders(1),
)
val totalNumOrders: NumOrders = NumOrders(numOrdersList.map(_.asInt).sum)
This approach is not particularly ergonomic. Every time we handle one of these value classes we need to unwrap the
value, and often (as when summing NumOrders
) we have to re-wrap the Int
value at the end.
Worse, there’s no real difference between doing things that should be allowed (eg summing NumOrders
) and doing things
that the compiler would ideally forbid, like combining two CustomerId
s:
val id1 = CustomerId(815787)
val id2 = CustomerId(664096)
val _ = CustomerId(id1.asInt + id2.asInt) // 🤦 this is nonsense!
Using value classes in this way does bring some value. The semantic meaning of values is much clearer thanks to the descriptive type names. But it comes at a high cost in terms of ergonomics. There must be a better way.
Manually adding behaviour to value classes
A relatively obvious approach is to manually add the appropriate behaviour to our value classes. In this example, we’ll use type classes from the cats functional programming library to achieve this. We could add the behaviour directly, but as we will see there are advantages to using type classes.
Since both CustomerId
and NumOrders
are ordered, we provide an instance of Order
to both. NumOrders
can be combined, so it gets a Monoid
too.
import cats.kernel._
final case class CustomerId(asInt: Int) extends AnyVal
implicit val customerIdOrder: Order[CustomerId] = Order.by(_.asInt)
final case class NumOrders(asInt: Int) extends AnyVal
implicit val numOrdersOrder: Order[NumOrders] = Order.by(_.asInt)
implicit val numOrdersMonoid: Monoid[NumOrders] = new Monoid[NumOrders] {
override def empty: NumOrders = NumOrders(0)
override def combine(x: NumOrders, y: NumOrders): NumOrders = NumOrders(x.asInt + y.asInt)
}
Now we get all the behaviour we wanted, and no more. CustomerId
has an ordering, but no way to combine two instances.
NumOrders
can be combined and is ordered, but is missing other behaviours for Int
that don’t make sense for a number
of orders (eg modulo).
Using Invariant
to define our type classes
Defining type classes in this way allows us to specify exactly which behaviours our value classes have. Usually, we are
simply exposing one or other behaviour from the underlying type. For CustomerId
, we are lifting the ordering from
Int
up to CustomerId
.
It turns out there is a simpler way to do this. We can use the imap
function provided by the Invariant
type class. All we need to do is provide functions for wrapping and unwrapping our value class (NumOrders.apply
and
.asInt
respectively).
import cats.syntax.invariant._
implicit val customerIdOrder: Order[CustomerId] = Order[Int].imap(CustomerId.apply)(_.asInt)
implicit val numOrdersOrder: Order[NumOrders] = Order[Int].imap(NumOrders.apply)(_.asInt)
implicit val numOrdersMonoid: Monoid[NumOrders] = Monoid[Int].imap[NumOrders](NumOrders.apply)(_.asInt)
In this example it’s probably simpler to use Order.by
for the Order
case. But see how much simpler it is to derive
the Monoid
for NumOrders
?
With this technique we can selectively extend the functionality of our value classes using only the type class that represents that functionality and functions for wrapping and unwrapping the value class.