The ZIO 2 “mental model”

As I work more with ZIO 2, I also find myself thinking a lot about the ZIO 2 mental model, by which I partially mean “a way of thinking about your code” and also “ZIO 2 best practices.”

These are my initial notes on that ZIO 2 mental model and best practices. Please note that I assume that you already have some exposure to ZIO, so I don’t take much time to review concepts like the ZIO R, E, and A parameters. (For that information, my ZIO 2 cheat sheet might be a little more helpful.)

Also, I hope that most of the following code is mine, but several snippets are from other sources that I don’t remember, including the official ZIO 2 docs.

Functional programming

Of course functional programming (FP) is a main concept you need to know. My main tenets of FP are:

  • Immutable variables (val fields in Scala)
  • Immutable data structures (List, Vector, and other immutable structures)
  • Pure functions
  • Use an IO-style effect handler for effects
  • Algebraic Data Types (ADTs) for domain modeling

If you’re not familiar with these concepts, I cover all of them (except for ADTs) in my now-free book, Learn Functional Programming The Fast Way!

“Effects”

One of the core concepts of ZIO is the concept of an effect. Basically the desire is to treat impure code as an effect, where that effect is also thought of as a blueprint.

Mathematically, because code wrapped in a ZIO is treated as a blueprint and it’s not run immediately, you can think of it as converting impure code to pure code. I’m not a math expert, so at the moment I don’t know how to describe that properly, but that is what I have read.

Regardless of whether a ZIO makes impure code pure, one thing it does do is to delay its execution. ZIO code doesn’t run right away, even if you have something like this:

val runNowPlease = ZIO.attempt(println("Sorry, I won’t run until later"))

Even though runNowPlease is created as a value (val) in Scala, this code doesn’t actually do anything. All it does it create a blueprint for something that can be run later, but right now, it won’t run and you won’t see any output. (You can verify that by following these instructions to run ZIO code in the Scala Ammonite REPL.)

To summarize this point, whenever you have an impure function, you should wrap it in the appropriate ZIO constructor. This turns that code into an effect (or blueprint), and it will only be run when you want it to run.

The ZIO 2 application launching point

Once you start writing ZIO 2 applications, a first important step to know is that a ZIO 2 application starts running in the run method of an object that extends ZIOAppDefault:

import zio.*

object TimerApp extends ZIOAppDefault:
    def run = Console.printLine("Hello, world")

Basically there’s a main method somewhere inside of ZIOAppDefault, and after doing some setup work, that main method calls the run method, which is defined as an abstract method in its code, which you implement as a concrete method in your code.

As you’ll see in the examples that follow, run can be implemented as a method or as a value (val field).

Name your main application code blueprint, equation, or program

I’ve found that it’s helpful to think of your application as being ONE big blueprint or equation. Like you’re writing a series of algebraic expressions, and then combining them all into that one equation.

For example, if you want to run a bunch of calculations in your application and then print a result:

  • Put all the algorithms and calculations inside a value with a meaningful name, like blueprint or equation
  • Then run that blueprint from your run method
  • Also in the run method, handle the result of your algorithms, such as printing their result and/or whatever errors come up when it ran

Personally, when I first got started, I found that it’s helpful to name your main application code something like blueprint, equation, or program. For example, here is a blueprint variable:

import zio.*
import zio.Console.*

object ZioMinimalDoneTest extends ZIOAppDefault:

    val failWithMsgEffect =
        printLineError("Usage: yada yada...").flatMap { _ =>
            ZIO.fail(new Exception("Usage error"))
        }

    val blueprint =
        for
            args <- ZIOAppArgs.getArgs
            rez  <- if args.size >= 1 then ZIO.succeed(()) else failWithMsgEffect
            _    <- printLine(s"\nfor is still running\n")
        yield
            args(0)

    // more code here ...

Then ...

Then run your blueprint/equation in ‘run’, and handle its results there

Next, run your blueprint/equation inside the ZIO run method, and — very importantly — handle its output there. By that I mean that your run method should look something like this:

def run = blueprint.foldZIO(
    failure => printLineError(s"FAILURE = $failure"),
    success => printLine(     s"SUCCESS = $success")
)

Also, I think of the ZIO run method as being “the end of the world.” By that I mean that you give it your equation so it can run it, and once the equation has “finished,” its results are evaluated here inside run as that end of the world.

Note: “The end of the world” verbiage is not my own. I think I first saw that in a Haskell book.

Learn your options for handling ‘run’

Next, learn what your options are inside of the run method, i.e., the different ways you can handle the success and failure conditions there. For instance, you can use fold or foldZIO:

def run = blueprint.foldZIO(
    failure => printLineError(s"FAILURE = $failure"),
    success => printLine(     s"SUCCESS = $success")
)

You can use catchAll:

def run = blueprint.catchAll( e =>
    Console.printLineError(s"SOMETHING NASTY HAPPENED: $e"),
)

exitCode is another option:

val run = blueprint.exitCode

Your might be able to use a for expression with other techniques:

val run: ZIO[Any, Nothing, Unit] =
    for
        rez <- ZIO.fromEither(intEither).catchAll(e => Console.printLine(s"Error occurred: ${e.getMessage}"))
        _   <- Console.printLine(s"OUTPUT: ${rez}")
    yield
        ()

A for expression is like using a series of flatMap (and map) calls, so if your for expression is short, you can use flatMap instead:

blueprint.flatMap(rez => Console.printLine(rez))

Here’s flatMap with ZIO.foreach:

override val run = program.flatMap { (lines: Seq[String]) =>
    ZIO.foreach(lines) { line =>
        Console.printLine(line)
    }
}

Once you start using ZEnv and ZLayer, you’ll start using provide, and then handle your blueprint’s results:

val run =
    blueprint.provide(
        Client.default,
        Scope.default
    ).foldZIO(
        failure => Console.printLineError(s"failure = $failure"),
        success => Console.printLine(s"success = $success")
    )

Here’s provide along with flatMap:

override val run =
    blueprint.provide(
        Client.default,
        Scope.default
    ).flatMap(todo => Console.printLine(todo))

Understand how ZIO values work in a for-expression

Another key is to understand how ZIO values work inside for-expressions.

For example, in the following code snippet, the zMakeInt function yields the ZIO type shown, and then I use it inside the for-expression.

The way the for-expression works is that the "1" string in the first line can be converted to an Int, so a is bound to the integer value 1.

But then on the next line, the "uh oh" string fails to convert to an Int, and the for-expression is immediately exited. That’s a first key point about this example:

How ZIO values work inside for-expressions

A second key point is that while zMakeInt returns a ZIO[Any, NumberFormatException, Int] type, a is an Int on the left side of the <- symbol. This is the way for-expressions work, and the same is true if the value on the right side of the -> symbol is a ZIO, an Option, an Either, or a Try.

Don’t wrap everything in a ZIO

Another key is that you should not wrap every value inside a ZIO. For instance, if you have a function that (a) returns an Int, (b) returns an Int for all possible input values, and (c) does not throw an exception, that’s great just as it is. That’s a pure function, and there’s no need to wrap it inside a ZIO.

Wrapping other pure values like this are also generally unnecessary:

val rez = ZIO.success(42)  // wrong: no need to wrap 42

Become a ZIO error-handling ninja

Learn how, when, and where to use ZIO.attempt, ZIO.succeed, ZIO.fail, orElseFail, exit, refineToOrDie, and more.

Also, make sure you know how for expressions short-circuit when an error/failure occurs.

TODO: Share some examples here.

Become a ZIO debugging ninja

ZIO methods like debug, tap, and tapError are some of your friends when it comes to debugging ZIO code.

TODO: Share some examples here.

Remember to sequence ZIO effects

For beginners it’s also important to remember to sequence your ZIO effects. As Natan Silnitsky warns in this ZIO pitfalls article, this ZIO 1.x code will compile, but it’s wrong because the console effect will not be executed:

ZIO.when(inventory.isEmpty) {
    console.putStrLn("Inventory is empty!")
    failOrder(orderId)
}

The two effects inside ZIO.when need to be sequenced, like this:

ZIO.when(inventory.isEmpty) {
    console.putStrLn("Inventory is empty!") *>   //<-- note the operator here
    failOrder(orderId)
}

Don’t return a ZIO in a yield block

In that same article, Mr. Silnitsky notes that you should not return a ZIO value in the yield block of a for-expression. As he notes, “The code inside yield is already effectful because a for-comprehension is just syntactic sugar for a sequence of flatMaps followed by a map.”

He further notes that if you return a ZIO in the yield area, you’ll end up returning a ZIO inside a ZIO, i.e., a ZIO[_,_,ZIO[B]].

TODO: Write more about this.

Create or find a good ZIO 2 cheat sheet

I’ve also found that it’s helpful to find or create a good ZIO 2 cheat sheet. I’ve started my own here, and this ghostdogpr cheat sheet is in much better shape than mine.

The reason I mention having a cheat sheet to refer to is because ZIO has a fairly large number of built-in functions that you can use to simplify different situations. It helps to have a cheat sheet to start with, and then you can jump to the ZIO docs for more details.

For example, a good cheat sheet will have a “run” section that tells you that you can use ZIO.foldZIO in your run code, and then you can find more details about that in this ZIO folding page. (TODO: My cheat sheet does not do that yet.)

More, coming soon ...

I’ll write more about other topics and issues as I keep writing more and more ZIO code. A few more come to mind:

  • For example, one issue for me personally is that ZIO.either didn’t work quite the way I expected (i.e., my misunderstanding), so I’ll try to get examples of that out here.
  • Understanding when you need to use flatMap, *>, or a for expression is also important.
  • ZIO is also fiber-ready, so it’s fairly simple to create parallel/concurrent applications.