ZIO and the Power of IOs (Part 1) (Scala 3 Video)
Like the Cats Effect IO
type, the ZIO
type is an incredibly souped-up IO
data type. As mentioned, in Functional Programming, Simplified, I show how to write a simple IO
type in less than a page of source code, but industrial-strength IO
types have taken years to create.
So, after a quick JIT note, let’s start writing some Scala/FP ZIO code.
JIT: Scala 2 applications
Before we get into ZIO, we need one quick JIT lesson, and it goes like this: One way to create a Scala 2 application looks like this:
object Hello extends App {
println("Hello")
}
In this code, App
is a type that’s provided by the Scala 2 SDK, and you extend it with an object
, as shown, to create an application. In Scala 3, the main
method approach I show in this book replaces this Scala 2 approach. (There’s another way to start Scala 2 applications, but that isn’t important for this lesson.)
ZIO applications
I mention that because ZIO takes a similar approach, and you create ZIO applications like this:
import zio.{ZIOAppDefault, Console}
object Zio101 extends ZIOAppDefault:
def run = Console.printLine("Hello, World!")
All you have to do is import the ZIO types that are necessary, and then create an object
that extends ZIO’s ZIOAppDefault
type. This type is described as the ZIO runtime, and it evaluates the rest of your application. As a quick recap, just:
- Create an
object
that extendsZIOAppDefault
- Define a
run
method inside thatobject
to start your application running - And then the rest of your application is evaluated as a ZIO application, and all your equations/blueprints start executing
If you dig into the ZIOAppDefault
source code, I suspect that somewhere in there you’ll find a function named something like runUnsafe
.
ZIO + Scala CLI 101
In that code I created the simplest-possible ZIO “Hello, world” application, using ZIO’s Console
type. To take this a step further, save this code in a file named Hello1.scala
to create a complete Scala 3 + ZIO + Scala CLI application:
//> using scala "3"
//> using lib "dev.zio::zio::2.0.2"
// run me like this:
// scala-cli Hello1.scala
// scala-cli Hello1.scala --watch
import zio.{ZIOAppDefault, Console}
object Zio101 extends ZIOAppDefault:
def run = Console.printLine("Hello, World!")
As shown in the comments, you run it like this at your operating system command line with scala-cli
:
$ scala-cli Hello1.scala
If you haven’t used ZIO before — congratulations! You just ran your first Scala/FP ZIO application, and if you think about where this book first started, that’s quite an accomplishment.
ZIO + Scala CLI 102
A next logical step is to demonstrate how to add a ZIO variable to your application:
import zio.{ZIOAppDefault, ZIO, Console}
import java.io.IOException
object Zio102 extends ZIOAppDefault:
val hello: ZIO[Any, IOException, Unit] =
Console.printLine("Hello, World!")
val run = hello
The first difference in this application is that I create hello
as an instance of a ZIO
variable:
val hello: ZIO[Any, IOException, Unit] =
As I mentioned before, the mental model for thinking of the ZIO
type is that it’s a description of a workflow, and you can think of it as a function, that given an R
can produce either an A
or an E
:
f(r: R): Either[E, A]
and in that code:
R
stands for the environment that is passed inE
is like theEither
error-handling typeA
is like theEither
success data type that you really want (i.e., the “happy path” or “happy result”)
Therefore, you can read this variable and data type:
val hello: ZIO[Any, IOException, Unit] =
like this:
R
is the Scala typeAny
, which is the ZIO way of saying that the environment doesn’t matterE
is anIOException
, meaning that the code on the right side of the=
symbol can throw that exceptionA
isUnit
, because we’re printing output to STDOUT, so the return type isn’t important
Once again, if you can read that, you’ll do just fine with ZIO: it’s just like writing line after line of algebra, or blueprint code.
NOTE: In the real world, you don’t have to always show each variable’s data type.
A second difference in this example is that I replaced def run
with val run
:
val run = hello
-------
I suppose this is one of those Scala “magic tricks” I was going to avoid, but let me explain it, because it’s not that bad.
You can do this in Scala because run
is probably defined something like this somewhere in the ZIOAppDefault
code:
trait SomeZioTrait:
def run: ZIO[A, B, C]
Of course it’s more complicated than that, but you can think of it that way. The key is that when run
is defined like this inside a Scala trait
, it can be read like this:
- There’s a
trait
namedSomeZioTrait
, and a Scalatrait
is like an interface in Java 8+, so it can be used to define both abstract and concrete methods. run
is defined as an abstract, somewhat vague thing that takes no input parameters, and has the typeZIO[A, B, C]
.- As I describe in the Scala Cookbook and Functional Programming, Simplified, the magic trick is that when you define
run
as an abstract thing like this, it can later be implemented by extending classes as adef
function OR as aval
field. This gives people who extend your trait power in how they want to implementrun
.
The summary of those bullet points is that as a designer, you define a field in a trait
as an abstract def
with no input parameters. This lets programmers who later extend your trait implement that field as a def
or as a val
, and the key is that this is more flexible for them.
Getting back to our current application, if you run Zio102
, you’ll see that it works just like Zio101
.
ZIO 103: ZIO.attempt
Given that background, let’s take another step. Rather than using ZIO’s built-in Console
type, let’s imagine that it doesn’t exist, so instead, we’ll need to wrap the Scala io.StdIn.readLine
function with ... well ... something.
This is where you begin to see ZIO’s power and flexibility. ZIO has built-in factory methods for just these needs. In this case, if you imagine that the readLine
function can throw an exception, you define a variable as follows to (a) prompt a user for their name, and (b) read their name in:
val username: ZIO[Any, Throwable, String] =
ZIO.attempt(StdIn.readLine("What’s your name? "))
A few keys about this code:
readLine
is used to both (a) prompt the user for their name and (b) return whatever they type.- Because I said to imagine that the code can throw an exception, the second parameter in
ZIO[Any, Throwable, String]
is aThrowable
(and it could also beException
orIOException
). - Recall that this second parameter is ZIO’s
E
parameter, and it’s similar toEither
’s error parameter. - Also because of the assumption that the code can throw an exception, I wrap the
readLine
code inZIO.attempt
.attempt
is a ZIO factory method that you use when you literally attempt to do something that may fail. Because we’re imagining thatreadLine
can fail, we useattempt
here.
As you’ll see shortly, in other situations, where a block of code can’t really fail, we’ll wrap that code with ZIO.succeed
instead of ZIO.attempt
. (As I mentioned, ZIO has many different factory methods for all sorts of situations.)
With this step of knowledge, our next little, complete ZIO application looks like this:
import zio.*
import scala.io.StdIn
object Zio103 extends ZIOAppDefault:
// new: wrap procedural code that can fail with `attempt`
val username: ZIO[Any, Throwable, String] =
ZIO.attempt(StdIn.readLine("What’s your name? "))
val run = username
If you run that code with Scala CLI, you’ll see that we can now prompt a user for input. Now we just need to use their input.
Update: All of my new videos are now on
LearnScala.dev