Cats Effect vs ZIO
Type-safe, composable asynchronous and concurrent programming for Scala
The tagline on the ZIO website
The pure asynchronous runtime for Scala
The tagline on the Cats Effect website
In this article, I aim to compare two excellent Scala libraries: ZIO and Cats Effect. Both provide the tooling for building asynchronous and concurrent applications.
Both are battle-tested and used by numerous companies in production. Finally, one and the other are based on the functional programming paradigm - they implement a functional effect system. We will discuss the philosophy behind libraries, core features, similarities, and differences. I won’t get into nitty-gritty details - many aspects of libraries deserve separate articles.
I will focus on the latest installments of both: ZIO 2.x series and Cats Effect 3.x.
The IO monad
Cats Effect and ZIO use a specialized data type for suspending computations called an IO monad. In CE, it’s called IO and for ZIO it’s just ZIO (although some specialized aliases like URIO, or Task are also used, more on this later).
The IO monad allows for suspending the computations that return some value and potentially perform a side effect. The key idea is that those actions can be composed and manipulated like any other value in functional programming. Programs created via composed IOs can be evaluated, ideally on the application's entry point, the so-called “end-of-the-world”. The piece of code responsible for efficiently running those programs is called the runtime, and it’s a core feature of both libraries.
Core features
Both libraries provide multiple combinators allowing for suspending side-effecting, impure computations and manipulating them. Let’s do a quick overview.
Cats Effect
The simplest way in CE to turn impure code into a functional effect is to embed computations with IO
. We can do it with the apply
method.
val io: IO[Int] = IO(StdIn.readInt())
Since the action is lazily evaluated (suspended), we can safely reuse the value in various parts of our program and expect it to behave the same.
If we’re sure our code won’t perform any side effects and simply returns the value we can use IO.pure
to wrap it into IO.
val pureIO: IO[Int] = IO.pure(42)
CE also provides multiple combinators for creating commonly used effects, to name a few:
- reading current time (
IO.realTime
) - reading a new line from standard input (
IO.readLine
) - printing line (
IO.printLine
) - waiting some time (
IO.sleep
) - and many more
IO
values are monadic and provide methods like map
and flatMap
. We can use them to combine IOs to form bigger programs:
//> using dep "org.typelevel::cats-effect:3.5.1"
val program: IO[String] = for {
_ <- IO.println("What is your name?")
name <- IO.readLine
_ <- IO.sleep(1.second)
_ <- IO.println(s"Hello, $name")
} yield name
The result type of the CE program is IO
. It’s parametrized with a single type argument A, which determines the type of value returned from the successfully evaluated program.
CE provides the trait IOApp
that we can use to create an entry point for the application.
//> using dep "org.typelevel::cats-effect:3.5.1"
import cats.effect.{IO, IOApp}
import scala.concurrent.duration.*
object CatsApp extends IOApp.Simple {
override def run: IO[Unit] =
for {
_ <- IO.sleep(2.seconds)
_ <- IO.println("Hello World")
} yield ()
}
This is the preferred way to bootstrap the CE application. It brings in the default runtime of CE, which evaluates the program.
ZIO
ZIO’s counterpart for IO.apply
is ZIO.attempt
. It takes side-effecting code and puts it in a ZIO context. We can also wrap pure values into ZIO with ZIO.succeed
.
val t: ZIO[Any, Throwable, Long] =
ZIO.attempt(System.currentTimeMillis())
ZIO offers a similar set of operators for common actions. For instance, we can use Console.readLine
to read the line of text from standard input or Clock.currentTime
to get the time or ZIO.sleep to block the flow of the program for a predetermined amount of time. All operators return ZIO instances that can be composed with flatMap
and map
functions.
val zio: ZIO[Any, IOException, Int] = for {
_ <- Console.printLine("Calculating random number:")
_ <- ZIO.sleep(2.seconds)
result <- Random.nextInt
} yield result
The structure of the program looks similar to the CE app, but what stands out are the result types. Instead of a single generic parameter, the ZIO type has as many as three. We’ll dig deeper into the meaning of all these parameters in the following paragraphs, let’s just do a quick overview now.
The last parameter A has the same meaning as the sole parameter of the IO from CE - it’s the result type of value returned from successful evaluation - the success channel. The second type is the error channel - E - it determines the type (or types) of errors the application can fail with. Nothing
in the error channel means the program won’t fail (or at least it is not expected). The first one is the environment - R - it identifies all dependencies required to evaluate the program. The program with the environment of type Any
describes the application with no dependencies.
For convenience, ZIO defines several aliases. For instance, UIO[A]
describes an effect that will produce value A
, won’t fail with any expected error, and has no environment dependencies. You can check all available aliases here.
Like CE, ZIO offers a helper trait for bootstrapping a new app. ZIOApp
brings in the default ZIO runtime.
//> using dep "dev.zio::zio:2.0.15"
import zio.*
object ZIOApp extends ZIOApp {
def run: ZIO[ZIOAppArgs, IOException, Unit] =
for {
_ <- ZIO.sleep(2.seconds)
_ <- Console.printLine("Hello World")
} yield ()
}
Aside from the result type signature, the overall look & feel of both libraries is similar so far. Let’s get to the first major difference - error handling.
Partner with Scala Experts to build complex applications efficiently and with improved code accuracy. Working code delivered quickly and confidently. Explore the offer >>
Error handling
Both libraries are built around the idea of asynchronous processing, where single computation can run on multiple threads during its lifetime. For that reason, we can’t just simply use regular exception-throwing and catching mechanisms provided out-of-the-box by the language. Hence, CE and ZIO provide specialized machinery to raise, propagate and deal with errors.
ZIO
ZIO has a unique approach to handling errors. It distinguishes between three possible error types:
- expected errors called failures
- unexpected errors - defects
- fatal errors
Let’s start with the expected errors. ZIO’s goal is to make sure that we handle them correctly. This is done by putting the expected error types in the second type parameter of ZIO - the error channel. Whenever we raise an error with ZIO.fail
, it is automatically placed in the type signature.
val failedZIO: ZIO[Any, String, Nothing] = ZIO.fail("Ups :(")
If we try to compose the ZIO with another with a different error type, it will fail to compile. This way ZIO ensures that we have somehow dealt with the error. To fix the issue, we need to either transform the errors via one of many available operators or remove them from the signature. We can do it by handling the error.
def app: ZIO[Any, Throwable, Unit] = for {
name <- Console.readLine("What's your name?")
_ <- failedZIO //this will prevent the code from compiling
_ <- Console.printLine(s"Hello, $name!")
} yield ()
ZIO provides a rich and flexible API for managing errors. For instance, we can handle only some of them with catchSome
or all at once with catchAll
.
failedZIO.catchAll(error => Console.printLine(s"Error: $error"))
ZIO supports interop with APIs that use Either
for managing errors. We can move the error from ZIO’s error channel into Either
located on the success channel (ZIO[R, E, A] => ZIO[R, Nothing, Either[E, A]]
) with ZIO.either
. To go from ZIO[R, Nothing, Either[E, A]] => ZIO[R, E, A]
we can use ZIO.absolve
.
And what about defects? They represent exceptional erroneous behavior of the application that is not expected to happen in the normal flow. We shouldn’t catch them. Instead, ZIO guidebooks recommend using the Let it crash approach. We can raise a defect with ZIO.die
.
val crashedZIO = ZIO.die(new IllegalStateException("SAD"))
Unlike failures, which can be of arbitrary type, reported defects need to be a subtype of Throwable
. Although usually, it’s not necessary, we can still catch defects with operators like catchAllDefects
. There’s also orDie
that translates the regular failure (with the restriction that it must be a subtype of Throwable
) to defect and resurrect
, which transforms the defect into failure.
Finally, fatal errors represent catastrophic failures of JVM, like OOM errors. ZIO doesn’t provide any way to intercept or handle them.
Cats Effect
CE has a more straightforward approach to handling errors. It doesn’t have any typed error channels. We can only raise errors that are subtypes of Throwable
. Then we either catch them downstream, or an unhandled exception will crash the application. So, in this regard, they behave similarly to ZIO’s defects.
To raise the error, we can use IO.raiseError
.
val failedIO: IO[Nothing] = IO.raiseError(
new IllegalStateException("NOPE")
)
To intercept we can employ handleError
or handleErrorWith
.
failedIO.handleErrorWith (
error => IO.println(s"Error: $error")
)
Under the hood, CE uses MonadError from Cats with the error type fixed to Throwable
.
But can we have typed errors in CE? We can still use nested Either
inside of IO
. We can utilize two utility methods that can be used to transform errors from Either
into a CE error channel. With the attempt
method, we can go from IO[A]
to IO[Either[Throwable, A]]
, and rethrow
does the opposite transformation. Beware, those methods are not foolproof. For instance, nothing stops us from calling the attempt
method multiple times and ending up with deeply nested Either
.
val io: IO[Either[Throwable, Either[Throwable, Int]]]
= IO(1).attempt.attempt //doesn't really make sense
Handling nested monad stacks (like Either
nested inside IO
) can be unwieldy. Hence, there’s a specialized class of data types that make it easier: monad transformers. Still, the monad transformers experience is not as smooth as working with ZIO’s built-in error channel.
To get more info on handling errors in CE please check this great guide.
Structuring of the application
Let’s get to another topic - how do we structure Scala applications written with functional effect systems? The pieces of the business code might depend on other parts of the system that implement some other chunks of the logic. We can also have infrastructure dependencies (so-called application context): logger, database managers, or HTTP clients.
The most straightforward solution for passing dependencies down the object graph is using constructors. Another scala-specific way is utilizing implicits (given/using in Scala 3).
The first approach is usually used for business logic and the latter for infrastructure-related context.
In functional Scala, we rarely depend on reflection-based DI frameworks for wiring up dependencies. The most common solutions are either manually creating an object graph or using a macro-based utility library like MacWire.
The solutions described above apply to any Scala app, so is there something more ZIO or CE can offer in this regard?
ZIO
We talked about errors in ZIO and how it’s using its type parameter to ensure exceptions are properly handled. But what about the environment type parameter? We can use it to describe the dependencies required to evaluate a particular ZIO program. The dependencies represent layers of the application described by the data type called ZLayer.
Let’s take an example. Supposing we want to create an app that uses a layer consisting of a plain in-memory map. First, let’s define a contract for our layer - an interface:
trait KVStore {
def put(key: Int, value: String): ZIO[Any, Throwable, Unit]
def get(key: Int): ZIO[Any, Throwable, Option[String]]
}
We can now summon the layer with ZIO.service
or invoke the function on the layer with ZIO.serviceWithZIO
or ZIO.serviceWith
.
val app: ZIO[KVStore, Throwable, Unit] = for {
_ <- ZIO.serviceWithZIO[KVStore](_.put(1, "Adrian"))
_ <- ZIO.serviceWithZIO[KVStore](_.put(2, "Zygmunt"))
user <- ZIO.serviceWithZIO[KVStore](_.get(1))
_ <- Console.printLine(s"User: $user")
} yield ()
ZIO will detect that we’re referencing a layer in the code and adjust the environment type accordingly. In this case, the environment type contains KVStore
. If we try to assign the ZIO type to another without the requirement for the dependency, we’ll get a descriptive compile-time error message:
───────────── ZIO APP ERROR─────────────
─────────────────────────────────────
Your effect requires a service that is not in the environment.
Please provide a layer for the following type:
1. KVStore
If we want to get rid of the error, we’ll have to provide the appropriate layer. But first, let’s add the implementation for the trait:
class InMemKVStore(val s: Ref[Map[Int, String]]) extends KVStore {
override def put(key: Int, value: String): Task[Unit]
= s.update(_.updated(key, value))
override def get(key: Int): Task[Option[String]]
= s.get.map(_.get(key))
}
And now we’re ready to create the layer. We will put the code in a companion object:
object InMemoryKVStore {
val layer: ZLayer[Any, Nothing, InMemoryKVStore] = ZLayer {
Ref.make(Map.empty[Int, String]).map(InMemoryKVStore(_))
}
}
To deliver the layers, we need to use the method provide
(and pass all layers at once) or provideSome
(only provide some of them).
val appWithDeps: ZIO[Any, Throwable, Unit] =
app.provide(InMemoryKVStore.layer)
Layers are highly composable. We can create layers that depend on each other. For instance, we can have a database access layer that requires the logger. Layers are flexible as well, they can be created from various sources: ZIO effects, functions, values, or even from resources that require lifecycle management.
Layers are a compelling and unique feature of ZIO. It’s a valuable addition to the mechanism already provided by the language (like implicits). ZIO has its opinionated way of constructing the object graph: use constructors for declaring dependencies, but then reify the constructors as values using layers. To learn more about structuring ZIO apps please check this great article.
Cats Effect
CE has no direct built-in equivalent for ZIO’s layers, so usually, we just stick with the features offered by the language.
Another solution for providing application context, quite often found in CE codebases, is using Reader monad. Usually, we use a monad transformer for Reader - ReaderT (also known by another name - Kleisli). It has some drawbacks - ReaderT
and IO
are distinct monads and can’t be directly composed together. If we need to use both in the same for-comprehension, we need to lift the IO
to ReaderT
with ReaderT.liftF
.
val app: ReaderT[IO, KVStore, Unit] = for {
_ <- ReaderT((store: KVStore) => store.put(1, "Adrian"))
_ <- ReaderT((store: KVStore) => store.put(2, "Zygmunt"))
user <- ReaderT((store: KVStore) => store.get(1))
//we need to lift IO to ReaderT
_ <- ReaderT.liftF(IO.println(s"User: $user"))
} yield ()
We can then provide the required dependencies with the run
method:
val program: IO[Unit] = app.run(InMemoryKVStore)
Resource management
CE and ZIO are built around the concept that safe resource management is paramount.
Interestingly, they use slightly different mechanisms to reliably free resources after they are no longer needed.
Cats Effect
Let’s start with CE. It provides a particular type named Resource
. It describes the actions necessary to manage the resource lifecycle: how to initialize and shut it down. We can make a new Resource
with method make
. It requires two arguments: effect describing how to open the resource and function returning effect specifying how to close it.
def fileWriter(fileName: String): Resource[IO, FileWriter] =
Resource.make(
IO(new FileWriter(fileName))
)(
writer => IO(writer.close())
)
We can then employ it with a method use
.
fileWriter("foo.txt").use(writer =>
IO(writer.write("Hello World!"))
)
The Resource
is a monad and can be composed with flatMap
. This way, we can easily create a hierarchy of resources that will be opened sequentially and closed conversely.
val resources: Resource[IO, (FileWriter, Supervisor[IO])] = for {
writer <- fileWriter("foo.txt")
supervisor <- Supervisor[IO]
} yield (writer, supervisor)
We can think of Resource
as a companion of IO
. It has a set of similar methods, but those two are different monads, so they can’t be composed together (similarly to ReaderT
and IO
). If we need to use both IO
and Resource
in the same for-comprehension, we need to lift the IO
to Resource
with Resource.eval
.
val resources: Resource[IO, FileWriter] = for {
writer <- fileWriter("foo.txt")
_ <- Resource.eval(IO.println("Allocating file writer"))
} yield writer
ZIO
ZIO 1.x has a quite similar resource-management model to CE. It also has a special datatype called Managed that can be manipulated similarly to Resource
and cooperated with ZIO
.
The approach has changed with ZIO 2 and the introduction of Scope
. Similar to Resource
, a scope represents the lifetime of one or more resources. We can create a scope with the method ZIO.acquireRelease
.
def fileWriter(fileName: String):ZIO[Scope, Throwable, FileWriter] =
ZIO.acquireRelease(
ZIO.attempt(new FileWriter(fileName))
)(
fw => ZIO.succeed(fw.close())
)
As you can see, unlike the Resource.make
method acquireRelease
doesn’t create some specialized object, but rather a regular ZIO
. The only difference is it has a particular environment value named Scope
. Since it’s a plain old ZIO effect, we can compose it freely with flatMap
or use it with any operator that requires ZIO.
The magic happens when we use the ZIO.scoped
operator. We can pass to it any ZIO workflow that contains Scope
in its environment. The operator creates necessary resources, provides them, and closes them after the workflow. By default, resources open in the order they were added to the scope and close in reverse order, but that can be customized.
The returned type no longer contains Scope
in the environment. It indicates there are no longer any resources for handling.
def run: ZIO[ZIOAppArgs, Throwable, Unit] =
ZIO.scoped {
for {
writer <- fileWriter("foo.txt")
_ <- ZIO.attempt(writer.write("Hello World!"))
} yield ()
}
Concurrency
Both CE and ZIO are great tools for building concurrent applications. A foundation of their concurrency systems is fiber. It’s a lightweight logical thread that represents a sequence of actions. In our case, it’s a list of operations suspended with IO monad and sequenced with flatMap. ZIO and CE runtime can multiplex tens of thousands of fibers over a few system threads. To get more details, please read my blog post about CE concurrency.
To start a new fiber in CE we simply call start
on the IO. ZIO’s counterpart method is called a fork
. To wait for the result of the fiber we should call join
(ZIO & CE). We can stop the fiber and cancel its computation with cancel
(CE) or interrupt
(ZIO)
//ZIO 2.x.x
val fiber1: Task[Fiber.Runtime[Throwable, Unit]] =
ZIO.attempt(heavyComputations).fork
//CE 3.x.x
val fiber2: IO[FiberIO[Unit]] = IO(heavyComputations).start
Both libraries support higher-level operators following the paradigm of structured concurrency. The fiber's lifecycle conforms to the code's syntactic structure with this approach. For instance, we can use operators like parSequence
(CE) or collectAllPar
(ZIO) to run multiple operations in parallel. It will spin up several fibers to run operations concurrently and close them when they’re no longer necessary.
//ZIO 2.x.x
val result1 = ZIO.collectAllPar(listOfZIOTasks)
//CE 3.x.x
import cats.syntax.all.*
val result2 = listOfIOTasks.parSequence
Again, both libraries provide similar APIs and possibilities. Still, there are notable differences. In ZIO, Fibers are supervised and tied to the lifecycle of their parents. If we fork new fibers from some fiber (parent) and it terminates, all its children will also be interrupted. To create unmanaged fiber, we need to use forkDaemon
operator. On the contrary, in CE, fibers are unsupervised. We need to use a special resource Supervisor that can bind the lifecycle of the fibers to its own. The difference might seem subtle, but it can be confusing when you switch between libraries.
ZIO and CE come with powerful, concurrency-enabled standard libraries. We can utilize semaphores, atomic references, concurrent queues, and many more. Moreover, there are community-driven projects like ZIO Actors or Cats STM giving us even more power.
Streaming
With streaming, only the chunk of the data is loaded into the memory at a particular moment. Hence we can work with infinite data with only finite resources. ZIO and CE can brag about proven and efficient libraries supporting streaming operations. In CE, we use fs2 as a dependency, and ZIO comes with ZIO Streams.
Both streaming libraries share some design choices. Both are functional, pull-based, and operate on chunks of data (as a performance optimization). They also provide a similar, high-level, declarative API that we can use to express complex, concurrent workflows.
Let’s check a simple example stream composed with fs2:
val stream: Stream[IO, Int] = Stream.emits(1 to 10)
.filter(_ % 2 == 0)
.map(_ * 2)
.evalTap(num => IO.println(s"Number $num"))
Streams in fs2 are lazily evaluated, so to consume it, we need to invoke compile
and then one of finalizing methods:
for {
//we need to compile to consume the stream
result <- stream.compile.fold(0)(_ + _)
_ <- IO.println(s"Result: $result")
} yield ()
Now let’s go to the ZIO example:
val stream: ZStream[Any, IOException, RuntimeFlags] =
ZStream.fromIterable(1 to 10)
.filter(_ % 2 == 0)
.map(_ * 2)
.tap(num => Console.printLine(s"Number $num"))
for {
result <- stream.run(ZSink.foldLeft(0) (_ + _))
_ <- Console.printLine(s"Result: $result")
} yield ()
At first glance, both examples look quite similar. There’s an important difference. ZIO Streams expose an additional data type we can work with - the ZSink
. The purpose of the sink is similar to the compile method in fs2 - to consume the stream. They have the advantage of being a value that can be manipulated and composed with other sinks.
The philosophy, design, and ecosystem
In previous paragraphs, we went through both libraries' most significant similarities and differences. Now let’s talk about the design goals that the developers of both libraries took.
ZIO aims to be as pragmatic as possible. It goes with a battery-included approach - it tries to offer everything you need to build a modern functional application out of the box.
On the other hand, CE is more lightweight and less opinionated. Many of the features built-in in ZIO, in CE are provided by a wide range of external libraries.
Moreover, CE is following a Tagless Final approach: all datatypes, like Resource
, Ref
, or Stream
from fs2 can take abstract F[_]
as an effect type. The only requirement for F[_]
is that it implements typeclasses provided by the Cats Effect library. Hence, we’re not bound to IO as the monad implementation. That additional flexibility is not available in ZIO.
CE and ZIO have rich and rapidly evolving ecosystems of libraries for database access, JSON parsing, HTTP clients and servers, and many more. CE libraries usually use the Tagless Final and can be used with any effect system that implements CE typeclasses. Fortunately, ZIO provides them via module interop-cats. Furthermore, with that module, we can also run ZIO libraries with CE.
Summary
We’re coming to the end of this short comparison of Scala’s popular effect systems. I didn’t give any scores or strong opinions, but I hope this article will be helpful to you if you’re struggling to decide whether to choose CE or ZIO for your next project. I also didn’t do any benchmarks, which might be an interesting topic for another article.
Both libraries are worth trying out and are viable options for building modern FP-oriented apps. If you’d like to learn more about other FP libraries in Scala, please check my other article. And if you’re planning to get started with ZIO, please check our guide.