Timothy McCarthy

### Categories

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 CustomerIds:

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 CustomerIds:

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.