As I wrote in my ZIO “mental model” and best practices article, when I work with ZIO, I like to separate (a) my application from (b) the ZIO run
value. Specifically I mean that I like to handle the results of the application in the run
value. (If you’ve read my previous ZIO blog posts, when I say “application,” I mean our main equation or blueprint.)
There are quite a few different ways to write a ZIO run
value, and in this tutorial I want to show many of the different ways I know, or at least those I can remember today. :)
Background: A sample ZIO application
To help explain what I mean, here’s a little ZIO 2 application, where the main application is implemented in the for
expression in the blueprint
value:
//> using scala "3"
//> using lib "dev.zio::zio::2.1.1"
package zio_done
import zio.*
import zio.Console.*
object ZioArgsTest extends ZIOAppDefault:
val failWithMsgEffect =
printLineError("Usage: yada yada...").flatMap { _ =>
ZIO.fail(new Exception("Usage error"))
}
val blueprint =
for
args <- ZIOAppArgs.getArgs
_ <- if args.size >= 1 then ZIO.succeed(()) else failWithMsgEffect
_ <- printLine(s"\ndebug: for is still running\n")
yield
args(0)
val run = blueprint.foldZIO(
failure => printLineError(s"FAILURE = $failure"),
success => printLine( s"SUCCESS = $success")
)
Therefore, for the purposes of this article, when I say that I want to show different ways to handle the run
value --- which may also be implemented as a method --- I’m referring to this code:
val run = blueprint.foldZIO(
failure => printLineError(s"FAILURE = $failure"),
success => printLine( s"SUCCESS = $success")
)
As mentioned, in this example, blueprint
is our main application, and the run
value is where we handle the result of that application. A key here is that the result may be:
- A success value
- An error value
- A combination of a success and error value (some success, but an error, too)
Given that background, let’s look at how to implement the run
value.
Implementing run with foldZIO
As you just saw, one way to implement the run
value is with foldZIO
:
val run = blueprint.foldZIO(
failure => printLineError(s"FAILURE = $failure"),
success => printLine( s"SUCCESS = $success")
)
The benefit of this approach is that it lets us handle both the success and failure cases of our equation. Because in any non-trivial application you’ll want to know about both the success and failure cases, I suspect this is a very common way to implement run
.
Alternate: Add the ExitCode
As an alternate version of that implementation, here’s another approach where you also supply an ExitCode
as the run
value result:
val run = blueprint.foldZIO(
failure => printLineError(s"FAILURE = $failure").as(ExitCode.failure),
success => printLine(s"SUCCESS = $success").as(ExitCode.success)
)
NOTE: When I run command-line applications with Scala-CLI, I’m not seeing those ExitCode
values as the result of my ZIO applications, so there may be something else that’s needed there.
Implementing run with foldCauseZIO
Another related way to implement run
is with the foldCauseZIO
method that’s available on all ZIO
instances:
// With this approach you can control what’s printed in the `cause` area,
// so it’s not necessary to print anything, if you don’t want to.
// Per the ZIO docs: “This 'cause' version of the 'fold' operator is
// useful to access the full cause of the underlying fiber. So in case
// of failure, based on the exact cause, we can determine what to do.”
val run = blueprint.foldCauseZIO(
cause => printLineError(s"FAILURE = ${cause.prettyPrint}"),
success => printLine(s"SUCCESS = $success")
)
Implementing run with catchAll
Another approach is to use the catchAll
method that’s available on all ZIO
values:
val run = blueprint.catchAll( err =>
Console.printLineError(s"OMG, you won’t believe what happened: ${err.getMessage}"),
)
Here’s a more-complicated version of this approach:
val run = blueprint.catchAll { failure =>
printLineError(s"FAILURE = $failure")
}.flatMap { success =>
printLine(s"SUCCESS = $success")
}
And here’s another version of catchAll
that uses ExitCode
and map
:
val run = blueprint.catchAll { error =>
printLineError(s"An error occurred: ${error.getMessage}").as(ExitCode.failure)
}.map(_ => ExitCode.success)
At the moment I don’t know the benefits of these approaches compared to foldZIO
, but as you can see, they also let you access your application’s success and failure values.
Matching the ZIO ExitCode
Another approach is to match on your application’s ExitCode
, by which I mean the exit
value on your blueprint or equation:
val run =
for
exitCode <- blueprint.exit
_ <- exitCode match
case Exit.Success(value) =>
printLine(s"You said: ${value}")
case Exit.Failure(cause) =>
// you don’t have to print anything here
printLine(s"EXIT: FAILURE: ${cause}")
yield
()
This approach is useful when your blueprint/equation has a meaningful exit
value that you want to work with.
For very simple use cases
When you have a very simple use case --- such as for personal code that you’re never going to have anyone else use, so you don’t do any error-handling --- you can just use flatMap
on your blueprint:
val run = blueprint.flatMap { rez =>
printLine(s"Result: $rez")
}
However, it’s very important to know that this approach will throw an exception if your blueprint has a failure case, so again, only use this in little applications that only you will use.
Here’s another flatMap
example that I have combined with the ZIO
foreach
method:
val run = program.flatMap { (lines: Seq[String]) =>
ZIO.foreach(lines) { line =>
Console.printLine(line)
}
}
That solution shows how to handle a result that is a sequence like a Seq
, List
, or Vector
.
Another simple approach (exitCode)
Speaking of simple approaches, this is something else you can do if your blueprint returns a meaningful exit code:
val run = blueprint.exitCode
Again, I need to test the use of exit codes like this. As mentioned, I know they are not useful when running applications with Scala-CLI, but I assume they are useful when you package your application and run it with the scala
or java
commands.
TODO/NOTE: Claude says “This will automatically handle the success/failure cases and convert them to appropriate exit codes.”
flatMap and running other effects after blueprint
If you want to run other effects after blueprint
, here’s a flatMap
approach you can use:
val run = blueprint.flatMap { arg =>
for
_ <- printLine(s"The first argument is: $arg")
_ <- someOtherEffect
_ <- yetAnotherEffect
yield
ExitCode.success
}.catchAll { error =>
printLineError(s"An error occurred: ${error.getMessage}").as(ExitCode.failure)
}
Handling run with foldZIO, ExitCode, and ensuring
Another way to handle ZIO’s run
value is with the combination of foldZIO
and ensuring
:
val run: ZIO[Any, IOException, ExitCode] = blueprint.foldZIO(
failure => printLineError(s"FAILURE = $failure").as(ExitCode.failure),
success => printLine(s"SUCCESS = $success").as(ExitCode.success)
).ensuring(printLine("(this always prints)").orDie)
The ensuring
method is a little like the finally
clause in a try/catch/finally
expression in that it always runs.
If run gets an Either
Here’s an approach you can use if run
gets an Either
value back from the blueprint:
val run = blueprint.either.flatMap {
case Left(failure) => printLineError(s"FAILURE = $failure")
case Right(success) => printLine(s"SUCCESS = $success")
}
As shown, this approach also lets you handle the success and failure cases.
run, orElse, and flatMap
This is another approach I found for handling run
, and its value is that it provides a fallback effect in the failure case:
val run = blueprint.orElse {
// whatever fallback effect you need here
printLineError("The equation/blueprint failed")
}.flatMap { result =>
// note: `result` is `()` in the failure case
printLine(s"SUCCESS = $result")
}
Providing dependencies (layers/ZLayer) with provide
When you need to provide dependencies --- also known as layers in ZIO 2 --- here’s an approach you can use with provide
or provideLayer
:
val run = blueprint.flatMap { value =>
printLine(s"The return value is: $value")
}.provideLayer(someLayer)
.exitCode
A key here is that you use provide
or provideLayer
to provide dependencies to your blueprint/equation. For example, if your application needs access to a database or a pool of database connections, you provide those to your blueprint at this point --- when the run
value first starts. In this use case:
run
provides whatever initial values/dependencies your application needs- When your application finishes, it also handles that result
I’ll add some better examples here over time, but I think I saw this one in the official ZIO documentation:
val run =
blueprint.provide(
Client.default,
Scope.default
).foldZIO(
failure => Console.printLineError(s"failure = $failure"),
success => Console.printLine(s"success = $success")
)
Just to show that you can combine these solutions, here’s a similar solution where I provide the same values to my blueprint
, and then use flatMap
to process the final result from blueprint
:
override val run =
blueprint.provide(
Client.default,
Scope.default
).flatMap(value => Console.printLine(value))
Because a ZIO application starts with the run
value, you can think:
- One of the first things that happens in this application is that the
Client
andScope
values are provided to theblueprint
. - One of the last things that happens is that this
flatMap
expression is run to print thatvalue
.
Using symbolic operators (methods) when providing layers
TODO: I need to add more to this section over time, but when you provide
multiple ZLayer
values to your application, you can use symbolic operators --- which are really Scala methods --- to provide those values at the same time. For example:
>>>
: This is like “and then,” meaning that it combines the layers sequentially, and the output of leftLayer
is used as input for rightLayer
:
blueprint.provide(leftLayer >>> rightLayer)
++
: This combines the output of two layers into a single layer:
blueprint.provide(leftLayer ++ rightLayer)
A real-world example here looks like this:
blueprint.provide(DatabaseLayer ++ LoggingLayer)
Per the ZIO documentation, this creates a layer “that has the requirements of both”, “to provide the capabilities of both.”
These are some other operators that can be used with provide
, and I will document these when I (a) use them and (b) have more free time:
&&&
<>
>+>
<+<
Until then, please see that ZIO documentation page for more details.
Summary
In summary, if you wanted to see many different ways to handle the run
value when writing a ZIO 2 application, I hope these examples are helpful.