ZIO Type Parameters
We said before that a value of type ZIO[R, E, A] is a functional effect that requires an environment R and may either fail with an E or succeed with an A.
Now that we understand what it means for a ZIO effect to be a blueprint for a concurrent workflow and how to combine effects, let’s talk more about each of the ZIO type parameters:
Ris the environment required for the effect to be executed. This could include any dependencies the effect has, for example, access to a database, or an effect might not require any environment, in which case, the type parameter will beAny.Eis the type of value that the effect can fail with. This could beThrowableorException, but it could also be a domain-specific error type, or an effect might not be able to fail at all, in which case the type parameter will beNothing.Ais the type of value that the effect can succeed with. It can be thought of as the return value or output of the effect.
A helpful way to understand these type parameters is to imagine a ZIO effect as a function R => Either[E, A]. This is not actually the way ZIO is implemented (this definition wouldn’t allow us to write concurrent, async or resource-safe operators, for example), but it is a useful mental model.
The following snippet of code defines this toy model of a ZIO effect:
final case class ZIO[-R, +E, +A](run: R => Either[E, A])As you can see from this definition, the R parameter is an input (in order to execute the effect, you must supply a value of type R), while the E and A parameters are outputs. The input is declared to be contravariant, and the outputs are declared to be covariant.
For a more detailed discussion of variance, see the appendix. Otherwise, just know that Scala’s variance annotations improve type inference, which is why ZIO uses them.
Let’s see how we can use this mental model to implement some basic constructors and operators:
final case class ZIO[-R, +E, +A](run: R => Either[E, A]) { self => def map[B](f: A => B): ZIO[R, E, B] = ZIO(r => self.run(r).map(f)) def flatMap[R1 <: R, E1 >: E, B]( f: A => ZIO[R1, E1, B] ): ZIO[R1, E1, B] = ZIO(r => self.run(r).fold(ZIO.fail(_), f).run(r))}
object ZIO { def attempt[A](a: => A): ZIO[Any, Throwable, A] = ZIO(_ => try Right(a) catch { case t: Throwable => Left(t) } ) def fail[E](e: => E): ZIO[Any, E, Nothing] = ZIO(_ => Left(e))}The ZIO.attempt method wraps a block of code in an effect, converting exceptions into Left values and successes into Right values. Notice that the parameter a to ZIO.attempt and ZIO.fail are by name (using the => A syntax), which prevents the code from being evaluated eagerly, allowing ZIO to create a value that describes execution.
We also implemented the flatMap operator previously discussed, which allows us to combine effects sequentially. The implementation of flatMap works as follows:
- It first runs the original effect with the environment
R1to produce anEither[E, A]. - If the original effect fails with a
Left(e), it immediately returns this failure as aLeft(e). - If the original effect succeeds with a
Right(a), it callsfon thatato produce a new effect. It then runs that new effect with the required environmentR1.
As discussed above, ZIO effects aren’t actually implemented like this, but the basic idea of executing one effect, obtaining its result, and then passing it to the next effect is an accurate mental model, and it will help you throughout your time working with ZIO.
We will learn more ways to operate on successful ZIO values soon, but for now, let’s focus on the error and environment types to build some intuition about them since they may be less familiar.
The Error Type
Section titled “The Error Type”The error type represents the potential ways that an effect can fail. The error type is helpful because it allows us to use operators (like flatMap) that work on the success type of the effect while deferring error handling until higher levels. This allows us to concentrate on the “happy path” of the program and handle errors at the right place.
For example, say we want to write a simple program that gets two numbers from the user and multiplies them:
import zio._
lazy val readInt: ZIO[Any, NumberFormatException, Int] = ???
lazy val readAndSumTwoInts: ZIO[Any, NumberFormatException, Int] = for { x <- readInt y <- readInt } yield x * yNotice that readInt has a return type of ZIO[Any, NumberFormatException, Int], indicating that it does not require any environment and may either succeed with an integer (if the user enters a response that can be parsed into a valid integer) or fail with a NumberFormatException.
The first benefit of the error type is that we know how this function can fail just from its signature. We don’t know anything about the implementation of readInt, but just looking at the type signature, we know that it can fail with a NumberFormatException and can’t fail with any other errors. This is very powerful because we know exactly what kind of errors we potentially have to deal with, and we never have to resort to “defensive programming” to handle unknown errors.
The second benefit is that we can operate on the results of effects, assuming they are successful, deferring error handling until later. If either readInt call fails with a NumberFormatException, then readAndSumTwoInts will also fail with the exception and abort the summation. This bookkeeping is handled for us automatically. We can multiply x and y directly and never have to deal explicitly with the possibility of failure. This defers error handling logic to the caller, which can retry, report, or defer handling even higher.
Being able to see how an effect can fail and to defer errors to a higher level of an application is useful, but at some point, we need to be able to handle some or all errors.
To handle errors with our toy model of ZIO, let’s implement an operator called foldZIO that will let us perform one effect if the original effect fails and another one if it succeeds:
final case class ZIO[-R, +E, +A](run: R => Either[E, A]) { self => def foldZIO[R1 <: R, E1, B]( failure: E => ZIO[R1, E1, B], success: A => ZIO[R1, E1, B] ): ZIO[R1, E1, B] = ZIO(r => self.run(r).fold(failure, success).run(r))}The implementation is actually quite similar to the one we walked through above for flatMap. We are just using the failure function to return a new effect in the event of an error and then run that effect.
One of the most useful features of the error type is being able to specify that an effect cannot fail at all, perhaps because its errors have already been caught and handled.
In ZIO, we do this by specifying Nothing as the error type. Since there are no values of type Nothing, we know that if we have an Either[Nothing, A], it must be a Right. We can use this to implement error-handling operators that let us statically prove that an effect can’t fail because we have handled all errors.
final case class ZIO[-R, +E, +A](run: R => Either[E, A]) { self => def fold[B]( failure: E => B, success: A => B ): ZIO[R, Nothing, B] = ZIO(r => Right(self.run(r).fold(failure, success)))}The Environment Type
Section titled “The Environment Type”Now that we have some intuition around the error type, let’s focus on the environment type.
We can model effects that don’t require any environment by using Any for the environment type. After all, if an effect requires a value of type Any, then you could run it with () (the unit value), 42, or any other value. So, an effect that can be run with a value of any type is actually an effect that doesn’t need any specific kind of environment.
The two fundamental operations of working with the environment are accessing the environment (e.g. getting access to a database to do something with it) and providing the environment (providing a database service to an effect that needs one, so it doesn’t need anything else).
We can implement this in our toy model of ZIO, as shown in the following snippet:
final case class ZIO[-R, +E, +A](run: R => Either[E, A]) { self => def provide(r: R): ZIO[Any, E, A] = ZIO(_ => self.run(r))}
object ZIO { def environment[R]: ZIO[R, Nothing, R] = ZIO(r => Right(r))}As you can see, the provide operator returns a new effect that doesn’t require any environment. The environment constructor creates a new effect with a required environment type and just passes through that environment as a success value. This allows us to access the environment and work with it using other operators like map and flatMap.
