ZIO 2: How to implement the 'run' value (different solutions)

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 and Scope values are provided to the blueprint.
  • One of the last things that happens is that this flatMap expression is run to print that value.

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.