Tagless Cheat Sheet

Tagless Final has prompted a number of discussions about whether it is or is not a good approach for building an application. This post doesn’t address any of them. It’s assumes that you have considered the issues relevant to your circumstances and have decided to use it. If this assumption does not hold for you1 then this post is not for you. Thanks for stopping by and enjoy your journey elsewhere on The Internets.

This post is a cheat sheet for doing Tagless Final in a Scala 2.13.x2 project using Cats and Cats Effect. It’s being written as a guide for a team to work with this approach day to day so it will skate over a lot of theory without going into it too deeply.

Cats Type Classes

Cats and Cats Effect provide a lot of type classes. The Cats documentation provides a handy diagram that shows them and their relationships. It can be helpful to be aware of the breadth of capabilities provided but you will typically be using a few of the most common ones.

Core Type Classes

Type Class Extends3
Functor
Applicative Functor
Monad Applicative
MonadError Monad
Sync MonadError (with error type as Throwable)

From this we can see that (for instance) a Monad is also a Functor because it is Applicative and Applicative is a Functor. Therefore if we were choosing to use Monad we do not need to separately specify Applicative or Functor as these are implied.

MonadError differ from the other type classes in that it has two type parameters, one of which is the type of the error. Sync provides these but only has a single type parameter for the result type. This works because Sync fixes the error type to be Throwable.

Type Class Capabilities

Type Class Provides What you want it for
Functor map Converting an F[A] to an F[B]
Applicative pure You have an A and need and F[A]
Monad flatMap You want to use for comprehensions4
MonadError attempt You want to do something that might fail and get back an F[Either[E,A]] which will contain either the successful result as a Right or the error as a Left
MonadError raiseError You want to raise an error in a pure and typesafe fashion
MonadError handleErrorWith You want to provide an error handling function produce an F[A] (that may itself be an error)
MonadError recover You want to provide a PartialFunction that can convert some errors back into success
MonadError recoverWith You want to provide a PartialFunction that can convert some errors into a different F[A] which can be a success or a new error
Sync delay You want to encapsulate a side-effecting operation for later execution in a fashion that is pure and type safe.

This table isn’t exhaustive so your favourite capability may not be listed. Check the Cats documentation for a more complete discussion with examples.

If you look at the Cats implementation you’ll see that the capabilities listed here will sometimes come from other type classes. For instance Monad actually gets flatMap from the FlatMap type class. I suggest starting with the set given and introducing more specific type classes as you become more familiar with what you will need in various scenarios.

Applying Type Classes

When using Cats type classes in addition to importing the type classes you’re using you will also need to import a number of implicits. This can be done using a single import5.

import cats.implicits._

Higher Kinded Types

In order to do tagless final we need to be using higher kinded types. Consider a simple type with a type parameter:

class Example[T] { }

In order to instantiate Example we need to specify what type should be used for T. We haven’t put any constraints on it so it could be anything. For instance:

val intExample: Example[Int] = new Example[Int]()

A higher kinded type requires that the type parameter itself have a type parameter:

class HigherKindedExample[F[_]] {}

If we now try to repeat the previous example with Int we get a compiler error as Int takes no type parameter:

val intHigherKindedExample: HigherKindedExample[Int] = new HigherKindedExample[Int]()

results in the error:

Int takes no type parameters, expected: 1
  val intHigherKindedExample: HigherKindedExample[Int] = new HigherKindedExample[Int]()

We can use any type that takes a type parameter to make this work, such as Option[T]:

val optionHigherKindedExample: HigherKindedExample[Option] = new HigherKindedExample[Option]()

We can also specify that the higher kinded type takes more than one type parameter:

class MultipleTypeParametersExample[G[_, _, _]] {}

In this example a type with three type parameters would be required.

We can but a higher kinded type on classes as shown as well as on traits and functions. The name of the type parameters is not important. F[_] is common but not required.

Adapting Type Parameter Requirements

There will be cases where you want to use a type that has a different set of type parameters than is required where you want to use it. A common example of this is MonadError. This is defined as MonadError[F[_], E] but in most scenarios where we want to use it we need a type that takes only a single F[_] type parameter. We can adapt this with the Scala type keyword to produce a type with E specified. Using Throwable as the error type is common if you are working with Sync or Cats Effect’s IO so we can define a new type ThrowableMonadError as follows:

type ThrowableMonadError[F[_]] = MonadError[F, Throwable]

We can now use ThrowableMonadError wherever we need a type that has a type parameter that itself takes a single type parameter. The compiler will enforce that the error type lines us with other types that are using MonadError6.

Making Code Tagless

We make our code tagless by introducing a higher kinded type parameter and then doing some additional magic to cause the type classes we want to be available.

Traits

For reasons we’ll go into below you can’t make a trait tagless directly. We can add a higher kinded type parameter but we cannot specify the type class on the trait. This may make you wonder what use traits are in this situation. Their primary utility is an abstraction that allows you to break coupling between code. Checking all the type classes required can be provided will be done using the implementation of the traits when you compose your program so the lack of type classes on the trait is in practice not a significant concern.

Let’s define a trait for some example service:

trait ExampleService[F[_]] {
  def processTheThing(thing: String): F[Int]
}

Classes

On classes we can start to require type classes. Let’s say our very simple initial implementation converts thing to an Int by getting the length and then returns this in an F7. We’re going to need to get the length into the F, which means we need pure from Applicative. Let’s define a class that will get an Applicative:

class ExampleServiceImpl[F[_]: Applicative] extends ExampleService[F] {
  ...
}

When we instantiate ExampleServiceImpl the compiler will ensure that there is available an Applicative type class for F[_]. We’ll get to how in a second. First, let’s provide an implementation:

class ExampleServiceImpl[F[_]: Applicative] extends ExampleService[F] {
  def processTheThing(thing: String): F[Int] = Applicative[F].pure(thing.length)
}

The Cats types can (when using this syntax) be accessed using the type class name with [F] appended, and you can then call their various functions. In this case we’re using pure to get the length count into F.

Explicit Implicits

This is all working with the magic8 of implicits9. What the compiler does in this instance is produce an implicit property on the class, and then Applicative's apply method picks up this implicit when we use Applicative[F]. We can do this directly if we choose:

class ExampleServiceImpl[F[_]](implicit F: Applicative[F]) extends ExampleService[F] {
  def processTheThing(thing: String): F[Int] = F.pure(thing.length)
}

Here we’re saying we need an Applicative[F] and that it should be an implicit in the class. We can directly access it in this usage so it doesn’t need to be implicit here but making it implicit means it will be picked up implicitly where ExampleServiceImpl is instantiated. It’s also required for a variety of other uses (such as in for comprehensions). We called both the parameter and type parameter F because it’s a nice shorthand to say F.pure although we could have named the parameter any other valid identifier.

Type Classes With More Than One Parameter

This can also be used to deal with type classes that take more than one type parameter. For instance these implementations are equivalent10:

class ExampleServiceImpl[F[_] : ThrowableMonadError] extends ExampleService[F] {
  def processTheThing(thing: String): F[Int] = Applicative[F].pure(thing.length)
}

class ExampleServiceImpl2[F[_]](implicit F: MonadError[F, Throwable]) extends ExampleService[F] {
  def processTheThing(thing: String): F[Int] = F.pure(thing.length)
}

We can also provide the other type parameters for the type class using type parameters of the class:

class ExampleServiceImpl3[F[_], E](implicit F: MonadError[F, E]) extends ExampleService[F] {
  def processTheThing(thing: String): F[Int] = F.pure(thing.length)
}
Raising Errors

Let’s get a little more complicated and parse our string into an integer. If this fails we want this to raise an error. That means we want MonadError.raiseError (we’ll show both with and without ThrowableMonadError):

class ParseExampleService[F[_]: ThrowableMonadError] extends ExampleService[F] {
  def processTheThing(thing: String): F[Int] = thing.toIntOption.map(Applicative[F].pure)
    .getOrElse(MonadError[F, Throwable].raiseError(new Exception("Not an integer")))
}

class ParseExampleService2[F[_]](implicit F: MonadError[F, Throwable]) extends ExampleService[F] {
  def processTheThing(thing: String): F[Int] = thing.toIntOption.map(F.pure)
    .getOrElse(F.raiseError(new Exception("Not an integer")))
}

In the first implementation we need to use MonadError[F, Throwable].raiseError because the ThrowableMonadError doesn’t have the plumbing to just work. We could add that but I’m not doing that here to keep things simpler11. You can pick the style that works best for you.

Side Effects and For Comprehensions

Now let’s try a for comprehensions and some side effects. For the for comprehension we’ll need Monad. To do side effects we’ll need Sync12. The table above shows that Sync provides Monad so we don’t need to provide it directly.

class ComposeSideEffects[F[_]: Sync] {
  def compose: F[Unit] = for {
    _ <- Sync[F].delay(println("first side effect"))
    _ <- Sync[F].delay(println("second side effect"))
  } yield ()
}

This provides us an F[Unit] which describes an operation that performs two side effects, one after the other. If the first fails the second is not executed. Any failure is encapsulated by the F[_] which, as Sync extends MonadError with Throwable can be handled using the various functions available via MonadError, or will propagate to the unsafeRunSync or equivalent call that is executing the deferred operations.

Multiple Dependencies

Sometimes you will want to include multiple dependency because there is no single dependency that provides all the capabilities you want. This may be because you have defined your own or you wish to use dependencies from multiple external sources. Up until now all the dependencies have been Cats type classes but strictly speaking we only need a dependency to have a higher kinded type parameter and for there to be an implementation we can supply as the implicit parameter that can have whatever type we are supplying as F[_] as it’s own type parameter13.

If that didn’t make much sense don’t worry too much. Consider an example where you want to do for comprehensions but also use the Cats Effect supplied Clock which gets the current time. We could pass this as an explicit argument but we can also do it in a tagless fashion which is handy for this example.

class ClockService[F[_]: Monad : Clock] {
  def different: F[Long] = for {
    a <- Clock[F].realTime(NANOSECONDS)
    b <- Clock[F].monotonic(NANOSECONDS)
  } yield b - a
}

We can also directly specify two implicit parameters on the class to get the same effect.

Functions

We can also do tagless final directly on functions. This allows us to use it on objects where we don’t need a class instance. We can also add additional dependencies for specific functions. This means we can provide some capabilities provisionally. You require only Applicative on your class which is your base requirement but if the caller can provide a matching Sync then they will be able to call additional functions. Callers that cannot provide this cannot call the functions with the requirement but can call any other functions that do not have this additional requirement provided they can meet the base Applicative requirement.

object OnFunction {
  def returnAsPure[F[_]: Applicative, A](a: A): F[A] = Applicative[F].pure(a)
}

class ExtendedRequirements[F[_]: Applicative] {
  def returnAsPure[A](a: A): F[A] = Applicative[F].pure(a)

  def printA[A](a: A)(implicit F: Sync[F]): F[Unit] = F.delay(println(a))
}

Summary

This post shows a number of ways to make things more tagless final. Despite the length in practice it’s generally a matter of determining the most specific Cats type class that does what you require then applying it to your class or function using one of the mechanisms above14. This isn’t a complete guide to building software in this fashion but hopefully will help when working in codebases using this pattern.


  1. It might very well not. I can’t guess your specific circumstances. ↩︎

  2. May also be applicable to Scala 2.12.x and earlier but I haven’t tested. Scala 3 is going to change the syntax for type classes so any code examples will likely not be applicable. ↩︎

  3. This list is the type classes you also get from the other type classes in this table. Each of these also provides other type classes not relevant to this post. ↩︎

  4. flatMap is vastly more useful than just for comprehensions. ↩︎

  5. This imports all the standard Cats implicits. You can be more specific if you choose but I suggest starting with this as it is usually significantly easier. ↩︎

  6. Exactly how it does this is outside the scope of this post. Just remember that if you’re using Sync or IO you’ll need to use Throwable 15. ↩︎

  7. There’s no actual need to use an F[_] here or indeed write a function at all. This is an example only. ↩︎

  8. Implicits make a lot of things work but also make a lot of things obscure. ↩︎

  9. Scala 3 is changing this which will be a really welcome improvement. ↩︎

  10. There are some differences in the plumbing behind the scenes that are inconsequential in most cases. ↩︎

  11. Relatively speaking. ↩︎

  12. I am assuming we’re doing them synchronously. Cats Effect provides Async for asynchronous computations. ↩︎

  13. There are implications of this but they’re not relevant to this discussion. ↩︎

  14. Then fixing the missing import cats.implicits._ that is stopping you compiling. ↩︎

  15. You can adapt incompatible types but I’m not going to describe how here 16. ↩︎

  16. Or anywhere else probably. ↩︎