Skip to main content
Scala: Effects in ZIO
  1. Posts/

Scala: Effects in ZIO

·1626 words·8 mins·
Roman
Author
Roman
Photographer with MSci in Computer Science and a Home Lab obsession
Table of Contents

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 type
  • A - 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 everything

Let’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 ZIOs

ZIO 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 UIO and Task.

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 *> ziob

Sequence 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 <* ziob

Run 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.unit

Recursion 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 + prevSum

Fibonacci 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] = ???
}