Learn through the super-clean Baeldung Pro experience:
>> Membership and Baeldung Pro.
No ads, dark-mode and 6 months free of IntelliJ Idea Ultimate to start with.
Last updated: February 6, 2023
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.
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 operation. Console.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:
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"
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:
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.
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 ()
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.
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.
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.
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:
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
ZIO has two basic concurrency data structures, Ref and Promise, that are the basis for other concurrency data structures:
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.
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)
}
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:
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.
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.
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.