Using ZIO with Tagless-Final
In this post, I’ll explain how ZIO works with tagless-final, and when it makes sense to modify your type classes to take advantage of the new power provided by ZIO for modeling typed errors.
ZIO vs Other Effect Types
In ZIO, a value of type
IO[E, A] is an immutable value that describes an effectful program. The program may fail with some error of type
E, or produce a value of type
Other popular Scala libraries like Monix also have effect types, but ZIO is the only one that provides typed errors. The other effect types fix the error type to
Throwable, which means it is not possible to constrain if and how effects may fail at compile-time, leading to bugs and poor reasoning properties.
This difference in power means that the kinds of
IO and other effect types differ:
- ZIO’s effect type has kind
(*, *) -> *, which means that the type constructor for
IOtakes two type parameters: the type of errors, and the type of values.
- Other effect types have kind
* -> *, which means their type constructors take one type parameter: the type of values.
Ordinarily, you don’t notice this difference, because ZIO is designed to have excellent type inference. Scala figures out the type parameters for both
There’s one place, however, where you have to think more carefully about the difference between ZIO and older effect types: the tagless-final encoding of effects.
In the tagless-final pattern, you describe your effects using a type class, which typically takes a single type constructor
F[_] (with kind
* -> *). For example, we might have a type class that describes the effect of logging:
As demonstrated in a recent live-coding talk I did for Fun(c)tional Programming Group, using effect type classes like these has several benefits:
- It allows us to see what effects functions use, so we can better understand our functions without having to drill down into their implementations, and find all the functions they call (which is error-prone and takes lots of time);
- It allows us to reuse more code. For example, we can have different instances of the
Loggingtype class for different production environments. One can log remotely, one can log locally, and one can not log at all.
- It allows us to easily mock effectful code without having to interact with the real world (so our tests can be fast and deterministic) and without having to rely on painful mocking libraries.
Yet because ZIO’s effect type takes two type parameters, and traditionally, in tagless-final, our effects only take one type parameter, many developers have had the question: how can we use ZIO with tagless-final?
In the sections that follow, I’ll explore the two ways to use ZIO with tagless-final, and then give you some guidance on how to choose the best method for every situation.
Fixing the Error Type
Although we cannot make
IO implement a type class like
Logging directly, because it has the wrong kind, if we partially apply
IO to its first type parameter (that is, if we fix the error type), then we can create an instance for the partially applied type constructor.
In the following example, I assume we have a function
def logIo(message: String): IO[Nothing, Unit], which I use to implement the type class for any fixed error type
With this approach, we may now use the
Logging type class with the
IO type. Let’s say we have some function
processData that requires logging:
Then now we can call
IO[E, ?] for any error type
E, because our implicit function
IOLogging defines an entire family of
Logging instances across all type parameters
Sometimes, we can’t provide instances for all error types. For example, if our logging function
logIo can throw
IOException, then we have to constrain the error type in our instance for the
IO data type:
In this case, because we needed the ability to fail, we had to restrict the instance to a specific error type. This means we can no longer use
Logging for just any
IO type; it has to be one that can fail with an
IOException (or a supertype, like
In both cases, we were able to use the same type class we would use for other effect types. We didn’t need to change a thing.
Varying the Error Type
The alternative to fixing error types is to let them vary, which requires that we change our tagless-final encoding.
Let’s say we have the following type class, which allows us to execute some query locally or in a distributed fashion:
Let’s say the local execution cannot fail, but the distributed execution can fail due to network errors. In this case, we would like to capture the distinction between these methods, so developers know by looking at the types what can happen.
To do this, we could always use an
Either, but since the capability is baked into ZIO, there’s no need to add additional runtime and cognitive overhead. Instead, we can just change the kind of our effect parameter from
* -> * to
(*, *) -> *.
This lets us vary the error type for each method:
Now we can clearly see that
local queries cannot fail, but
distributed queries can fail due to network errors.
This type class does not limit us to ZIO, either. For the
Task type in Monix, for example, while we cannot create an instance of
Task directly, we can create an instance for
EitherT[Task, ?, ?] (
EitherT is a monad transformer that adds typed errors to a base monad).
This means that when you parameterize your type classes over effects that take two type parameters, developers can choose whether to use ZIO or to use another effect monad and layer on typed errors with the
EitherT monad transformer.
The choice about whether to use tagless-final with
F[_, _] is pretty straightforward:
- If the effects of your type class cannot fail, or can fail with only a single type of error across all methods, then you should parameterize your type classes with
F[_]. You can easily provide instances of this type for ZIO and other effect monads. No changes are required.
- If the effects of your type class can fail in different ways, then you should parameterize your type classes with
F[_, _], so you can precisely capture the different failure modes in the type class. You can easily provide instances of this type for ZIO. For other effect monads like
Task, you can provide instances for
EitherT[Task, ?, ?].
It’s easy to combine the two styles in one code base. For example, a data processing function that requires
F[_, _]: Analytics can call a logging function that requires
F[_]: Logging by simply requiring
L: Logging[F[IOException, ?]].
In this post, we’ve seen that the effect type in ZIO allows us to precisely model if and how computations may fail. We’ve also seen that because of this power, the kind of ZIO’s
IO type differs from the kind of other effect types—it takes two type parameters, rather than one.
Because of this difference, many developers wonder how to use ZIO with tagless-final. Fortunately, using tagless-final with ZIO is trivial. As long as all methods produce the same error type (or no error at all), then there’s no need to make any changes to the type classes.
In some cases, if we want our type classes to describe effects that can fail in different ways, we are able to improve on classic tagless-final by varying the error type. In this case, our type classes become parameterized over
F[_, _], allowing each method to specify its own error type. We can support other effect types by writing instances for
In summary, ZIO is not only compatible with tagless-final, but also lets us take tagless-final to the next level of compile-time precision, while still preserving full compatibility with older dynamically-typed effect monads.
If you like this blog post, don’t miss John A. De Goes’ upcoming 5 day training in Functional Scala, held remotely or on-site in the Scottish highlands.