1. Overview

ZIO is a zero-dependency library for asynchronous and concurrent programming in Scala. It’s a functional effect system in Scala.

There are several functional effect systems in functional programming in the Scala community, such as ZIO, Cats Effect, and Monix. In this tutorial, we’ll look at ZIO and its competitive features in the world of functional programming in Scala.

2. What Is the Functional Effect?

The functional effect is about turning the computation into first-class values. Every effect can be thought of as an operation itself, but a functional effect is a description of that operation.

For example, in Scala, the code println(“Hello, Scala!”) is an effect that prints the “Hello, Scala!” message to the console. The println function is of type Any => Unit. It returns Unit, so it is a statement.

But in ZIO, Console.printLine(“Hello, ZIO!”) is a functional effect of printing “Hello, ZIO!” on the console. It is a description of such an operationConsole.printLine is a function of type Any => ZIO[Any, IOException, Unit]. It returns the ZIO data type, which is the description of printing the message to the console.

So, in a nutshell:

  • an effect is about doing something, such as println(“Hello, Scala!”)
  • a functional effect is about the description of doing something, as in zio.Console.printLine(“Hello, ZIO!”)

3. Installation

We need to add two lines to our build.sbt file to use this library:

libraryDependencies += "dev.zio" %% "zio" % "2.0.6"
libraryDependencies += "dev.zio" %% "zio-streams" % "2.0.6"

4. The ZIO Data Type

The ZIO[R, E, A] is a core data type around the ZIO library. We can think of the ZIO data type as a conceptual model like a function from R to Either[E, A]:

R => Either[E, A]

This function, which requires an R, might produce either an E, representing failure, or an A, representing success. ZIO effects are not actually doing something, because they model complex effects, like asynchronous and concurrent effects.

Let’s write a “hello world” application using ZIO:

import zio._
import java.io.IOException

object Main extends ZIOAppDefault {
  val myApp: ZIO[Any, IOException, Unit] =
    Console.printLine("Hello, World!")

  def run = myApp
}

When we’re developing a ZIO application, we are composing ZIO values together to create the whole application logic, finally; we have a single ZIO value that we need to run using ZIO Runtime. The run method is the entry point of our application. The ZIO Runtime will call that function to execute our application.

The ZIO[R, E, A] data type encodes three different things about our effect:

  • R — the environment/dependency of our effect
  • E — the type of errors that our effect may throw
  • A — the return type of our effect

In the above example, the type of myApp is ZIO[Any, IOException, Unit]. The environment of this effect is Any. This means that the ZIO Runtime doesn’t need any particular layer to run the effect. This is because the Console is already part of the runtime, so we don’t need to provide it.The E type parameter is IOException, and the A parameter is Unit. This means that running this effect returns the Unit value or may throw IOException.

5. Composing ZIO Values

ZIO provides various combinators to compose and build our effects. For example, with flatMap, we can compose effects sequentially and feed the output of one effect to another:

import zio._
import zio.Console

for {
  _ <- Console.printLine("Hello! What is your name?")
  n <- Console.readLine
  _ <- Console.printLine("Hello, " + n + ", good to meet you!")
} yield ()

In this example, we composed three effects together. First of all, we print a message for the user to insert his/her name, then we read that from the console and feed it another effect, which is responsible for printing another message to the user.

The zip is another combinator for composing effects. We can zip together effects and create tuples from their results:

for {
  _ <- Console.printLine("Enter a new user name")
  (uuid, username) <- Random.nextUUID zip Console.readLine
} yield () 

6. Resource Safety

Another interesting feature of ZIO is that it has a nice resource-safe construct, ZIO.acquireReleaseWith, that prevents us from writing an application that mistakenly leaks resources:

ZIO.acquireReleaseWith(acquireEffect)(releaseEffect) {
  usageEffect
}

For example, let’s see a resource-safe way of reading from a file:

ZIO.acquireReleaseWith(
  ZIO.attemptBlocking(Source.fromFile("file.txt"))
)(file => ZIO.attempt(file.close()).orDie) { file =>
  ZIO.attemptBlocking(file.getLines().mkString("\n"))
}

The way resource management is done in ZIO is through Scope data type. The data type represents the lifetime of one or more resources. For example, if we use acquireReleaseWith, Scope will be added to R environment of the ZIO Effect. This means that given effect requires Scope to be executed and all resources acquired will be released once Scope is closed.

We can provide Scope by using ZIO.scoped as shown below:

ZIO.scoped {
  ZIO.acquireReleaseWith(
    ZIO.attemptBlocking(Source.fromFile("file.txt"))
  )(file => ZIO.attempt(file.close()).orDie) { file => ZIO.attemptBlocking(file.getLines().mkString("\n")) }
}

Additionally, ZioApp provides default Scope, representing lifetime of the whole application, so if we won’t provide any Scope the default will be used and the resources will be released when application is closed.

7. ZIO Modules and Dependency Injection

ZLayer is the main contextual data type in ZIO***.* The ZLayer data type is used to construct a service from its dependencies**. So, the ZLayer[Logging & Database, Throwable, UserService] is the recipe of building UserService from Logging and Database services. We can think of ZLayer as a function that maps Logging and Database services to the UserService.

7.1. Writing Services

ZIO encourages us to use Module Pattern 2.0 to write ZIO services. With Module Pattern 2.0, we can define services with traits and then implement that using Scala classes. Also, we can use class constructors to define service dependencies.

Without going into details, let’s see how to define a service in ZIO:

// Service Definition
trait Logging {
  def log(line: String): UIO[Unit]
}

// Companion object containing accessor methods
object Logging {
  def log(line: String): URIO[Logging, Unit] =
    ZIO.serviceWithZIO[Logging](_.log(line))
}

// Live implementation of Logging service
class LoggingLive extends Logging {
  override def log(line: String): UIO[Unit] =
    for {
      current <- Clock.currentDateTime
      _ <- Console.printLine(s"$current--$line").orDie
    } yield ()
}

// Companion object of LoggingLive containing the service implementation into the ZLayer
object LoggingLive {
  val layer: URLayer[Any, Logging] =
    ZLayer.succeed(new LoggingLive)
}

In this way, we can write the whole application with interfaces, and at the end of the day, we provide layers containing all the implementations.

8. Fibers

ZIO’s concurrency model is based on fibers. We can think of fibers as lightweight user-space threads. They don’t use preemptive scheduling; rather, they use cooperative multitasking.

Let’s look at some of the important features of ZIO fibers:

  • Asynchronous— Fibers aren’t blocking like JVM threads, they are always asynchronous.
  • Lightweight— Fibers don’t use preemptive scheduling, and they yield their execution to each other using cooperative multitasking. So, they’re much more lightweight than JVM threads.
  • Scalable — Unlike JVM threads, the number of fibers isn’t limited to the number of kernel threads. We can have as many fibers as we want as long as we have sufficient memory.
  • Resource Safe — ZIO fibers have a structured concurrency model, so they don’t leak resources. When the parent fiber is interrupted or finishes its job, all child fibers will be interrupted. The child fibers are scoped to their parents.

Let’s execute two long-running jobs in two different fibers and then join them:

for {
  fiber1 <- longRunningJob.fork
  fiber2 <- anotherLongRunningJob.fork
  _ <- Console.printLine("Execution of two job started")
  result <- (fiber1 <*> fiber2).join
} yield result

9. Concurrency Primitives

ZIO has two basic concurrency data structures, Ref and Promise, that are the basis for other concurrency data structures:

  1. Refa functional mutable reference that is useful in writing stateful applications. The two basic operations are set and get. Ref provides us with an atomic way of updating a mutable reference.
  2. Promisea synchronization data type that helps us to synchronize multiple fibers. We can think of Promise as a placeholder that can be set exactly once. For example, when we want to wait for something to happen, we can use Promise. By waiting on a promise, the fiber blocks until the promise will be filled.

Let’s see how Refs can help us to write a counter in a concurrent environment:

for {
  counter <- Ref.make(0)
  _ <- ZIO.foreachPar((1 to 100).toList) { _ =>
    counter.updateAndGet(_ + 1)
      .flatMap(reqNumber => Console.printLine(s"request number: $reqNumber"))
  }
  reqCounts <- counter.get
  _ <- Console.printLine(s"total requests performed: $reqCounts")
} yield ()

Other concurrency data structures, such as Queue, Hub, and Semaphore, are built on top of Ref and Promise.

10. Parallel Operators

ZIO also has a variety of parallel operators, such as ZIO.foreachPar, ZIO.collectPar, and ZIO#zipPar, that help us to run an effect in parallel*.* Let’s try the foreachPar operator:

ZIO.foreachPar(pages) { page =>
  fetchUrl(page)
}

11. Asynchronous Programming

Asynchronous programming is essential when we have long-running jobs that depend on some I/O or blocking operations. For example, when we’re waiting for an event to occur, instead of waiting and consuming a thread, we can register a callback and continue to run the next operations. This style of callback-based asynchronous programming helps us not to block the thread and increases the responsiveness of our application, but it has some drawbacks:

  1. Callback Hell — Callbacks are great for simple cases, but they add some level of nesting and complexity to our programs, especially when we have lots of nested callbacks.
  2. Tedious Error Handling — When we use callbacks, we should be very careful where we use try/catch to catch exceptions. And this makes error handling tedious.
  3. Lack of Composability — Callbacks don’t compose. So, in the world of functional programming, it’s very hard to write composable components using callbacks.

By using the ZIO effect, we are saying goodbye to asynchronous programming with callback-based APIs.

First of all, ZIO has some good constructs to convert an asynchronous callback to a ZIO effect. One of these constructs is ZIO.async:

object legacy {
  def login(
    onSuccess: User => Unit,
    onFailure: AuthError => Unit): Unit = ???
}

val login: ZIO[Any, AuthError, User] =
  ZIO.async[Any, AuthError, User] { callback =>
    legacy.login(
      user => callback(IO.succeed(user)),
      err  => callback(IO.fail(err))
    )
  }

Secondly, we have lots of functional operators for error handling — for example, there are operators for catching errors, falling back to another effect in case of failure, and retrying.

12. STM

Considering the classic problem of transferring money from one account to another:

def transfer(from: Ref[Int], to: Ref[Int], amount: Int) = for {
  _ <- withdraw(from, amount)
  _ <- deposit(to, amount)
} yield ()

What if, in between the withdraw and deposit operations, another fiber comes to withdraw or deposit on the same accounts? This snippet code has a bug in the concurrent environment: It has the potential to reach a negative balance. So, we need to run both the withdraw and deposit operations in one single atomic operation.

In a concurrent environment, we need to run the whole block transactionally. With ZIO STM (Software Transactional Memory), we can write the whole transfer logic in the STM data type and then run that transactionally:

def transfer(from: TRef[Int], to: TRef[Int], amount: Int): ZIO[Any, String, Unit] =
  STM.atomically {
    for {
      _ <- withdraw(from, amount)
      _ <- deposit(to, amount)
    } yield ()
  }

ZIO STM provides us composable transactions in a declarative style in which all operations are non-blocking using lock-free algorithms. They commit the transaction when all conditions have been met.

13. Conclusion

In this article, we learned some important capabilities of the ZIO effect, which helps us to write real applications using the functional programming paradigm. Along the way, we learned the basics of how to write modular ZIO applications. We also found out some of the capabilities of concurrent programming with ZIO.

As always, the full source code for the examples is available over on GitHub.