Table of Contents
- Functional programming
- “Effects”
- The ZIO 2 application launching point (run)
- Name your main application code blueprint, equation, or program
- Then run your blueprint/equation in ‘run’, and handle its results there
- Learn your options for handling ‘run’
- Understand how ZIO values work in a for-expression
- Don’t wrap everything in a ZIO
- Become a ZIO error-handling ninja
- Become a ZIO debugging ninja
- Remember to sequence ZIO effects
- Don’t return a ZIO in a yield block
- Create or find a good ZIO 2 cheat sheet
- More, coming soon ...
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 (run)
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 initially 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
orequation
- 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.
this post is sponsored by my books: | |||
#1 New Release |
FP Best Seller |
Learn Scala 3 |
Learn FP Fast |
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))
See my article, Different ways to implement 'run' in ZIO 2 for many more examples.
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:
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 flatMap
s 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.)
this post is sponsored by my books: | |||
#1 New Release |
FP Best Seller |
Learn Scala 3 |
Learn FP Fast |
More, coming soon ...
I’ve started to create videos on programming with ZIO, and here is a first link to those:
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 afor
expression is also important. - ZIO is also fiber-ready, so it’s fairly simple to create parallel/concurrent applications.