In this post I’m exploring ZIO in ZIO course | Rock the JVM.
Understanding ZIO #
Starting with a simple IO monad (see MyIO implementation for more details):
case class MyIO[A](unsafeRun: () => A) {
def map[B](f: A => B): MyIO[B] =
MyIO(() => f(unsafeRun()))
def flatMap[B](f: A => MyIO[B]): MyIO[B] =
MyIO(() => f(unsafeRun()).unsafeRun())
}Adding Error Handling and Environment #
How would it handle errors?
Using Either[E, A] to represent either successful value or error value, and adding an environment parameter R:
R- Environment/Context type (input required for computation)E- Error typeA- Return value type
Variance #
For more in-depth explanation of variance, see Variance in Scala.
[-R]- it’s consuming R so it’s contravariant[+E]- it produces errors so it’s covariant[+A]- it’s covariant because it’s a producer of values of type A
case class MyZIO[-R, +E, +A](unsafeRun: R => Either[E, A]) {
def map[B](f: A => B): MyZIO[R, E, B] =
MyZIO(r => unsafeRun(r) match {
case Left(err) => Left(err)
case Right(value) => Right(f(value))
})
// R1 is a subtype of R
// E1 is a supertype of E
def flatMap[R1 <: R, E1 >: E, B](
f: A => MyZIO[R1, E1, B]
): MyZIO[R1, E1, B] =
MyZIO(r => unsafeRun(r) match {
case Left(err) => Left(err)
case Right(value) => f(value).unsafeRun(r)
})
}Using ZIO #
Now let’s look at the actual ZIO library and how to use it:
import zio._
// import zio everythingLet’s Create a ZIO #
Under the hood ZIO is ZIO[-R, +E, +A] which is familiar to us from the MyZIO example. Here we just return A with ZIO.succeed()
For this example:
- R = Any - Doesn’t take any environment
- E = Nothing - Doesn’t fail
- A = Int - Value produced will be int
val meaningOfLife: ZIO[Any, Nothing, Int] =
ZIO.succeed(42)We can also return errors with ZIO.fail():
val aFailure: ZIO[Any, String, Nothing] =
ZIO.fail("Something went wrong")ZIO.suspend - Lazy Evaluation #
ZIO.suspend is used to defer the construction of a ZIO effect until it’s actually run. This is useful for:
- Delaying expensive computations until needed
- Creating effects whose construction might fail
// The error type is Throwable because the
// suspended effect construction
// itself might throw exceptions when evaluated at runtime
val aSuspendedZIO: ZIO[Any, Throwable, Int] =
ZIO.suspend(meaningOfLife)Map and Flat Map #
val improvedMOL: ZIO[Any, Nothing, Int] = meaningOfLife
.map(_ * 2)
val printingMOL: ZIO[Any, Nothing, Unit] = meaningOfLife
.flatMap(mol => ZIO.succeed(println(mol)))For Comprehensions #
// for comprehensions
val smallProgram: ZIO[Any, Nothing, Unit] = for {
_ <- ZIO.succeed(println("what's your name"))
name <- ZIO.succeed(StdIn.readLine())
_ <- ZIO.succeed(println(s"Welcome to ZIO, $name"))
} yield ()Combinators and Transformers #
val anotherMOL = ZIO.succeed(100)
val tupledZIO: ZIO[Any, Nothing, (Int, Int)] =
meaningOfLife.zip(anotherMOL)
// Returns a Tuple
val combinedZIO: ZIO[Any, Nothing, Int] =
meaningOfLife.zipWith(anotherMOL)(_ * _)
// Can apply a function on both ZIOsZIO Type Aliases #
ZIO provides convenient type aliases for common patterns:
UIO[A] - Universal IO
#
UIO[A] = ZIO[Any, Nothing, A]
- No requirements (
Any) - Cannot fail (
Nothing) - Produces A
val aUIO: UIO[Int] = ZIO.succeed(99)URIO[R, A] - Universal Requiring IO
#
URIO[R, A] = ZIO[R, Nothing, A]
- Has requirements R
- Cannot fail (
Nothing) - Produces A
val aURIO: URIO[Int, Int] = ZIO.succeed(67)RIO[R, A] - Requiring IO
#
RIO[R, A] = ZIO[R, Throwable, A]
- Has requirements R
- Fails with Throwable
- Produces A
val anRIO: RIO[Int, Int] = ZIO.succeed(98)
val aFailedRIO: RIO[Int, Int] =
ZIO.fail(new RuntimeException("Oh god"))Task[A] - Task
#
Task[A] = ZIO[Any, Throwable, A]
- No requirements (
Any) - Can fail with Throwable
- Produces A
val aSuccessfulTask: Task[Int] = ZIO.succeed(89)
val aFailedTask: Task[Int] =
ZIO.fail(new RuntimeException("Something bad"))IO[E, A] - Error IO
#
IO[E, A] = ZIO[Any, E, A]
- No requirements (
Any) - Can fail with E
- Produces A
val aSuccessfulIO: IO[String, Int] = ZIO.succeed(34)
val aFailedIO: IO[String, Int] = ZIO.fail("AAAAA")Note: The most popular type aliases are
UIOandTask.
ZIO Operators and Combinators #
| Operator | Description | Example |
|---|---|---|
*> |
Sequence Right: Run both ZIOs, keep the result of the right one | zio1 *> zio2 |
<* |
Sequence Left: Run both ZIOs, keep the result of the left one | zio1 <* zio2 |
.as(value) |
Replace Value: Replace the ZIO’s result with a constant value | zio.as(42) |
.unit |
Discard Value: Replace the ZIO’s result with Unit () |
zio.unit |
.zip(other) |
Combine: Combine two ZIOs into a tuple | zio1.zip(zio2) |
.zipWith(other)(f) |
Combine with Function: Combine two ZIOs using a function | zio1.zipWith(zio2)(_ + _) |
.map(f) |
Transform: Transform the result value | zio.map(_ * 2) |
.flatMap(f) |
Chain: Chain ZIO computations | zio.flatMap(x => ZIO.succeed(x)) |
Common Patterns #
Sequence and Take Last Value #
When you need to run two effects in sequence but only care about the second result.
// Run both ZIOs, keep second result
def sequenceTakeLast[R, E, A, B](
zioa: ZIO[R, E, A],
ziob: ZIO[R, E, B]
): ZIO[R, E, B] =
zioa.flatMap(a => ziob.map(b => b))
// For-comprehension version
def sequenceTakeLast_v2[R, E, A, B](
zioa: ZIO[R, E, A],
ziob: ZIO[R, E, B]
): ZIO[R, E, B] =
for {
_ <- zioa
b <- ziob
} yield b
// Built-in operator: *> means "sequence right" (keep right result)
def sequenceTakeLast_v3[R, E, A, B](
zioa: ZIO[R, E, A],
ziob: ZIO[R, E, B]
): ZIO[R, E, B] =
zioa *> ziobSequence and Take First Value #
When you need the result from the first effect but also want to ensure a cleanup or side effect happens afterward.
// Run both ZIOs, keep first result
def sequenceTakeFirst[R, E, A, B](
zioa: ZIO[R, E, A],
ziob: ZIO[R, E, B]
): ZIO[R, E, A] =
zioa.flatMap(a => ziob.map(_ => a))
// For-comprehension version
def sequenceTakeFirst_v2[R, E, A, B](
zioa: ZIO[R, E, A],
ziob: ZIO[R, E, B]
): ZIO[R, E, A] =
for {
a <- zioa
_ <- ziob
} yield a
// Built-in operator: <* means "sequence left" (keep left result)
def sequenceTakeFirst_v3[R, E, A, B](
zioa: ZIO[R, E, A],
ziob: ZIO[R, E, B]
): ZIO[R, E, A] =
zioa <* ziobRun Forever #
Creates background tasks or services that run indefinitely.
// Infinite loop - stack-safe due to ZIO trampolining
def runForever[R, E, A](zio: ZIO[R, E, A]): ZIO[R, E, A] =
zio.flatMap(_ => runForever(zio))
// Same using *> operator
def runForever_v2[R, E, A](zio: ZIO[R, E, A]): ZIO[R, E, A] =
zio *> runForever_v2(zio)
// Example usage
val endlessLoop = runForever {
ZIO.succeed {
println("Running...")
Thread.sleep(1000)
}
}Convert Value #
Run an effect for its side effect but return a different value. Common for operations like save and return success message.
// Replace ZIO result with constant value
def convert[R, E, A, B](
zio: ZIO[R, E, A],
value: B
): ZIO[R, E, B] =
zio.map(_ => value)
// Built-in method
def convert_v2[R, E, A, B](
zio: ZIO[R, E, A],
value: B
): ZIO[R, E, B] =
zio.as(value)Discard Value #
Run an effect for its side effect and ignore the result completely. Used when you only care that something happened.
// Convert ZIO result to Unit
def asUnit[R, E, A](zio: ZIO[R, E, A]): ZIO[R, E, Unit] =
convert(zio, ())
// Built-in method
def asUnit_v2[R, E, A](zio: ZIO[R, E, A]): ZIO[R, E, Unit] =
zio.unitRecursion with Stack Safety #
ZIO provides stack safety through trampolining, allowing deep recursion without stack overflow.
// Regular recursion crashes with stack overflow
def sum(n: Int): Int =
if (n == 0) 0
else n + sum(n - 1) // stack overflow
// Stack safe via trampolining
// Map/flatMap chains evaluated on heap,
// not stack (tail recursive style)
def sumZIO(n: Int): UIO[Int] =
if (n == 0) ZIO.succeed(0)
else for {
current <- ZIO.succeed(n)
prevSum <- sumZIO(n - 1)
} yield current + prevSumFibonacci with Stack Safety #
Fibonacci sequence with ZIO, demonstrating lazy evaluation to prevent stack overflow.
// Regular fibonacci crashes with stack overflow
def fibo(n: Int): BigInt =
if (n <= 2) 1
else fibo(n - 1) + fibo(n - 2) // stack overflow
// Still gets stack overflow
def fiboZIO(n: Int): UIO[BigInt] = {
if (n <= 2) ZIO.succeed(1)
else for {
last <- fiboZIO(n - 1)
prev <- fiboZIO(n - 2)
} yield last + prev
// Still crashes because of eager evaluation
}
// The for comprehension above expands to:
// fiboZIO(n-1).flatMap(last =>
// fiboZIO(n-2).map(prev => last + prev))
//
// Problem: fiboZIO(n-1) gets evaluated first,
// causing deep recursion and stack overflow
// Using suspendSucceed for lazy evaluation
def fiboZIO_v2(n: Int): UIO[BigInt] = {
if (n <= 2) ZIO.succeed(1)
else for {
// Delays ZIO evaluation until needed
fibo1 <- ZIO.suspendSucceed(fiboZIO(n - 1))
fibo2 <- fiboZIO(n - 2)
} yield fibo1 + fibo2
}Running ZIO Effects #
There are several ways to run ZIO effects in your application:
Manual Runtime (Not Recommended) #
This is the low-level approach using the runtime directly
import zio._
object ZIOApps {
val meaningOfLife: UIO[Int] = ZIO.succeed(42)
def main(args: Array[String]): Unit = {
// Very clunky approach
val runtime = Runtime.default
given trace: Trace = Trace.empty
Unsafe.unsafe { unsafe =>
given u: Unsafe = unsafe
// run signature:
// def run[E, A](zio: ZIO[R, E, A])
// (implicit trace: Trace, unsafe: Unsafe): Exit[E, A]
println(runtime.unsafe.run(meaningOfLife))
}
}
}ZIOAppDefault (Recommended) #
The easiest and most common way to run ZIO applications:
object BetterApp extends ZIOAppDefault {
// ZIOAppDefault provides runtime, trace, and other dependencies automatically
override def run: ZIO[ZIOAppArgs & Scope, Any, Any] =
ZIOApps.meaningOfLife
.flatMap(mol => ZIO.succeed(println(mol)))
// Alternative: Instead of flatMap + println, you can use debug:
// ZIOApps.meaningOfLife.debug // would print: 42
}ZIOApp (Advanced) #
For advanced use cases where you need full control over the environment:
object ManualApp extends ZIOApp {
override implicit def environmentTag:
zio.EnvironmentTag[ManualApp.type] = ???
override type Environment = this.type
override def bootstrap:
ZLayer[ZIOAppArgs, Any, ManualApp.type] = ???
override def run:
ZIO[ManualApp.type & (ZIOAppArgs & Scope), Any, Any] = ???
}