Timothy McCarthy
Timothy McCarthy

Categories

In my previous post, I discussed using Invariant to add behaviour to value classes. Unfortunately, Invariant is not powerful enough to provide instances for higher-kinded type classes like Functor or Traverse. In this post, I’ll introduce InvariantK, a type class I’ve written to solve this limitation.

Value classes with a type parameter

As I discussed previously, wrapping common types in dedicated classes (“value classes”) comes with a number of advantages. Value classes have a semantic meaning that more common types do not, and we can use this technique to get help from the compiler to avoid bugs.

Sometimes, these principles apply to generic types in the same way as they apply to normal types.

Consider this type called Uncertain.

sealed trait Uncertain[+A]

case object Unknown             extends Uncertain[Nothing]
final case class Known[A](a: A) extends Uncertain[A]

Uncertain represents some value that we may know or not know. It is obviously very similar to Option. Known maps directly to Some, and Unknown maps to None. But Uncertain has a different semantic meaning, which is now clear everywhere we use it.

Compare:

final case class Person(
  name: String,
  age: Option[Int],
)

versus

final case class Person(
  name: String,
  age: Uncertain[Int],
)

The meaning of the age field in the first case is ambiguous and a little silly. Every person has an age, so how could it ever be None? But if we use Uncertain, it becomes clear what we’re attempting to model. We are handling the case where we don’t know the person’s age.

Using Invariant to define instances for Uncertain

In my last post I described how we can use the imap function provided by Invariant to easily define type class instances based on an underlying type. Given Uncertain is just like Option, can we do something similar here?

First of all, we’ll need two functions for converting between Option[A] and Uncertain[A]:

def optionToUncertain[A](option: Option[A]): Uncertain[A] = option match {
  case Some(a) => Known(a)
  case None    => Unknown
}

def uncertainFromOption[A](uncertain: Uncertain[A]): Option[A] = uncertain match {
  case Known(a) => Some(a)
  case Unknown  => None
}

For simple cases, using imap works well. For example, we can define an Eq instance easily:

import cats._
import cats.syntax.all._

implicit def eqForUncertain[A: Eq]: Eq[Uncertain[A]] = Eq[Option[A]].imap(optionToUncertain)(uncertainFromOption)

Eq[Uncertain[Int]].eqv(Known(1), Known(1)) // true
Eq[Uncertain[Int]].eqv(Known(1), Unknown)  // false

But what about the more complex operations on Option like .map? This comes from the Functor type class. If we had a Functor we could do useful things like describing whether a Person is over 18:

implicit val functorForUncertain: Functor[Uncertain] = ???

val p1 = Person(name = "👶", age = Known(3))
val p2 = Person(name = "💆", age = Unknown)

def isAdult(age: Int): Boolean = age >= 18

p1.age.map(isAdult) // 🙅 Known(false)
p2.age.map(isAdult) // 🤷 Unknown

Unfortunately, Invariant is not powerful enough to define a Functor for Uncertain. This is because Functor is a higher-kinded type class. Put another way, whereas type classes like Eq[A] and Semigroup[A] accept a simple type parameter, Functor[F[_]] only applies to types that themselves have a type parameter. We might say that the F[_] in Functor[F[_]] has to “have a type hole” or “be a type constructor”.

We need to find another solution.

The InvariantK type class

The solution is to create a type class similar to Invariant, but which works on higher-kinded types rather than normal types. There is a similar distinction between functions (which act on normal types) and FunctionK. Accordingly, what we need is something called InvariantK.

Consider how Invariant is defined (cf. Cats implementation):

trait Invariant[F[_]] {
  def imap[A, B](fa: F[A])(f: A => B)(g: B => A): B
}

If we move each concept in this definition “up” by one “kind”, we end up with:

import cats.~>

trait InvariantK[T[_[_]]] {
  def imapK[F[_], G[_]](tf: T[F])(f: F ~> G)(g: G ~> F): T[G]
}

This is pretty mind-bending. T[_[_]] is a doubly-higher-kinded type! And notice that f and g are now instances of FunctionK (using the ~> notation) to transform between the two higher-kinded types F[_] and G[_].

I’ve implemented InvariantK in my personal library of Cats utilities. It’s available here. We can use it to define a Functor for Uncertain:

import au.id.tmm.utilities.cats.classes.InvariantK
import au.id.tmm.utilities.cats.syntax.all._
import cats._
import cats.arrow.FunctionK
import cats.syntax._

val optionToUncertain: Option ~> Uncertain = new FunctionK[Option, Uncertain] {
  override def apply[A](option: Option[A]): Uncertain[A] = option match {
    case Some(a) => Known(a)
    case None => Unknown
  }
}

val uncertainToOption: Uncertain ~> Option = new FunctionK[Uncertain, Option] {
  override def apply[A](uncertain: Uncertain[A]): Option[A] = uncertain match {
    case Known(a) => Some(a)
    case Unknown  => None
  }
}

implicit val functorForUncertain: Functor[Uncertain] = Functor[Option].imapK(optionToUncertain)(uncertainToOption)

val p1 = Person(name = "👶", age = Known(3))
val p2 = Person(name = "💆", age = Unknown)

def isAdult(age: Int): Boolean = age >= 18

p1.age.map(isAdult) // 🙅 Known(false)
p2.age.map(isAdult) // 🤷 Unknown

Once we have Option ~> Uncertain and Uncertain ~> Option, we can define a Functor for Uncertain. In fact, we can use these FunctionK instances to define a number of type class instances for Uncertain:

implicit val monadErrorForUncertain: Monad[Uncertain]  = Monad[Option].imapK(optionToUncertain)(uncertainToOption)
implicit val traverseForUncertain: Traverse[Uncertain] = Traverse[Option].imapK(optionToUncertain)(uncertainToOption)
implicit val monoidKForUncertan: MonoidK[Uncertain]    = MonoidK[Option].imapK(optionToUncertain)(uncertainToOption)

Check out tmmUtils. It has a bunch of utilities I’ve found useful, including InvariantK.