Scala - ZIO Basics
ZIO's core abstraction is `ZIO[R, E, A]`, where R represents the environment (dependencies), E the error type, and A the success value. This explicit encoding of effects makes side effects...
Key Insights
- ZIO provides a purely functional effect system that makes side effects explicit, composable, and testable through its
ZIO[R, E, A]type representing computations requiring environment R, failing with E, or succeeding with A - The framework eliminates callback hell and simplifies error handling through built-in retry policies, timeouts, and resource management with automatic cleanup via
ZIO.acquireRelease - ZIO’s fiber-based concurrency model enables efficient parallel execution with
zipPar, racing effects withrace, and interruption support that properly cleans up resources without thread blocking
Understanding the ZIO Effect Type
ZIO’s core abstraction is ZIO[R, E, A], where R represents the environment (dependencies), E the error type, and A the success value. This explicit encoding of effects makes side effects first-class citizens in your type system.
import zio._
// Simple effect that always succeeds
val helloWorld: ZIO[Any, Nothing, Unit] =
ZIO.succeed(println("Hello, World!"))
// Effect that might fail
val divideNumbers: ZIO[Any, String, Int] =
ZIO.attempt(10 / 0).mapError(_.getMessage)
// Effect requiring environment
val getUserName: ZIO[Console, IOException, String] =
Console.readLine("Enter your name: ")
Type aliases simplify common patterns: UIO[A] for ZIO[Any, Nothing, A] (infallible), Task[A] for ZIO[Any, Throwable, A], and RIO[R, A] for ZIO[R, Throwable, A].
// Type aliases in practice
val infallible: UIO[Int] = ZIO.succeed(42)
val maybeThrows: Task[String] = ZIO.attempt(scala.io.Source.fromFile("data.txt").mkString)
val needsConsole: RIO[Console, Unit] = Console.printLine("Output")
Creating and Running Effects
ZIO provides multiple constructors for wrapping computations. Choose based on whether your code is synchronous, asynchronous, or blocking.
import zio._
object EffectCreation extends ZIOAppDefault {
// Wrap synchronous code
val syncEffect: Task[Int] = ZIO.attempt {
println("Performing calculation")
42 * 2
}
// Wrap async code with callbacks
def fetchUserAsync(id: Int): Task[String] =
ZIO.async[Any, Throwable, String] { callback =>
// Simulate async API
scala.concurrent.Future {
Thread.sleep(100)
if (id > 0) callback(ZIO.succeed(s"User-$id"))
else callback(ZIO.fail(new Exception("Invalid ID")))
}(scala.concurrent.ExecutionContext.global)
}
// Blocking operations on dedicated thread pool
val blockingIO: Task[List[String]] =
ZIO.attemptBlocking {
scala.io.Source.fromFile("large-file.txt").getLines().toList
}
def run = syncEffect.flatMap(result => Console.printLine(s"Result: $result"))
}
Running effects requires a ZIOAppDefault or explicit Unsafe.unsafe runtime. The framework handles execution, thread pools, and resource cleanup.
Composing Effects with Flatmap and For-Comprehensions
ZIO effects compose through flatMap, enabling sequential execution where each step depends on previous results.
import zio._
case class User(id: Int, name: String)
case class Order(userId: Int, total: Double)
def fetchUser(id: Int): Task[User] =
ZIO.attempt(User(id, s"User-$id"))
def fetchOrders(userId: Int): Task[List[Order]] =
ZIO.attempt(List(Order(userId, 99.99), Order(userId, 149.99)))
def calculateTotal(orders: List[Order]): UIO[Double] =
ZIO.succeed(orders.map(_.total).sum)
// Compose with flatMap
val workflow: Task[Double] =
fetchUser(1)
.flatMap(user => fetchOrders(user.id))
.flatMap(orders => calculateTotal(orders))
// For-comprehension syntax
val workflowReadable: Task[Double] = for {
user <- fetchUser(1)
orders <- fetchOrders(user.id)
total <- calculateTotal(orders)
} yield total
Use zipWith or zipPar when effects don’t depend on each other:
def getTemperature: Task[Double] = ZIO.attempt(22.5)
def getHumidity: Task[Double] = ZIO.attempt(65.0)
// Sequential execution
val weatherSeq: Task[(Double, Double)] =
getTemperature.zip(getHumidity)
// Parallel execution
val weatherPar: Task[(Double, Double)] =
getTemperature.zipPar(getHumidity)
Error Handling and Recovery
ZIO provides comprehensive error handling without try-catch blocks, making error paths explicit in types.
import zio._
def riskyOperation(value: Int): Task[Int] =
if (value < 0) ZIO.fail(new IllegalArgumentException("Negative value"))
else ZIO.succeed(value * 2)
// Catch and recover
val recovered: Task[Int] =
riskyOperation(-5).catchAll { error =>
Console.printLine(s"Error: ${error.getMessage}") *>
ZIO.succeed(0)
}
// Catch specific errors
val specificRecover: Task[Int] =
riskyOperation(-5).catchSome {
case _: IllegalArgumentException => ZIO.succeed(0)
}
// Fold over both success and failure
val folded: UIO[String] =
riskyOperation(-5).fold(
error => s"Failed: ${error.getMessage}",
value => s"Success: $value"
)
// Retry with policy
val withRetry: Task[Int] =
riskyOperation(-5).retry(Schedule.recurs(3) && Schedule.exponential(100.millis))
The orElse operator tries alternatives:
def primaryService: Task[String] = ZIO.fail(new Exception("Primary down"))
def backupService: Task[String] = ZIO.succeed("Backup response")
val resilient: Task[String] = primaryService.orElse(backupService)
Resource Management
ZIO’s acquireRelease ensures resources are cleaned up even when effects fail or are interrupted.
import zio._
import java.io.{File, PrintWriter}
def writeToFile(filename: String, content: String): Task[Unit] =
ZIO.acquireReleaseWith(
acquire = ZIO.attempt(new PrintWriter(new File(filename)))
)(
release = writer => ZIO.succeed(writer.close())
)(
use = writer => ZIO.attempt(writer.write(content))
)
// Scoped resources with ZIO 2.x
def scopedWrite(filename: String, content: String): Task[Unit] =
ZIO.scoped {
ZIO.acquireRelease(
ZIO.attempt(new PrintWriter(new File(filename)))
)(writer => ZIO.succeed(writer.close())).flatMap { writer =>
ZIO.attempt(writer.write(content))
}
}
For multiple resources:
def processFiles(input: String, output: String): Task[Unit] =
ZIO.scoped {
for {
reader <- ZIO.acquireRelease(
ZIO.attempt(scala.io.Source.fromFile(input))
)(source => ZIO.succeed(source.close()))
writer <- ZIO.acquireRelease(
ZIO.attempt(new PrintWriter(new File(output)))
)(w => ZIO.succeed(w.close()))
_ <- ZIO.attempt {
reader.getLines().foreach(line => writer.println(line.toUpperCase))
}
} yield ()
}
Concurrent and Parallel Execution
ZIO’s fiber model provides lightweight concurrency without blocking threads.
import zio._
def task1: Task[String] = ZIO.attempt {
Thread.sleep(1000)
"Task 1 complete"
}
def task2: Task[String] = ZIO.attempt {
Thread.sleep(500)
"Task 2 complete"
}
// Race - first to complete wins
val raced: Task[String] = task1.race(task2)
// Both in parallel, combine results
val parallel: Task[(String, String)] = task1.zipPar(task2)
// Collection of effects in parallel
val tasks: List[Task[Int]] = (1 to 10).map(i => ZIO.attempt(i * 2)).toList
val allParallel: Task[List[Int]] = ZIO.collectAllPar(tasks)
// Fork for background execution
val forked: Task[String] = for {
fiber <- task1.fork
_ <- Console.printLine("Main thread continues")
result <- fiber.join
} yield result
// Timeout
val withTimeout: Task[Option[String]] =
task1.timeout(750.millis)
Interruption propagates through fiber hierarchies:
val interruptible: Task[Unit] = for {
fiber <- ZIO.never.fork
_ <- Console.printLine("Interrupting...")
_ <- fiber.interrupt
} yield ()
Dependency Injection with ZLayer
ZIO’s environment system provides type-safe dependency injection without runtime reflection.
import zio._
trait Database {
def query(sql: String): Task[List[String]]
}
case class DatabaseLive(connectionString: String) extends Database {
def query(sql: String): Task[List[String]] =
ZIO.attempt(List("row1", "row2"))
}
object Database {
val layer: ZLayer[Any, Nothing, Database] =
ZLayer.succeed(DatabaseLive("jdbc://localhost"))
}
// Service using database
def fetchUsers: ZIO[Database, Throwable, List[String]] =
ZIO.serviceWithZIO[Database](_.query("SELECT * FROM users"))
// Provide dependency
val program: Task[List[String]] =
fetchUsers.provide(Database.layer)
Compose layers for complex dependencies:
trait Cache {
def get(key: String): Task[Option[String]]
}
object Cache {
val layer: ZLayer[Database, Nothing, Cache] =
ZLayer.fromFunction((db: Database) => new Cache {
def get(key: String): Task[Option[String]] = ZIO.succeed(None)
})
}
// Horizontal composition
val combined: ZLayer[Any, Nothing, Database with Cache] =
Database.layer >>> Cache.layer
ZIO transforms functional programming from academic exercise into practical tool for building robust, concurrent applications with explicit error handling and resource safety.