ZIO and the Power of IOs (Part 1)

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 extends ZIOAppDefault
  • Define a run method inside that object 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 in
  • E is like the Either error-handling type
  • A is like the Either 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 type Any, which is the ZIO way of saying that the environment doesn’t matter
  • E is an IOException, meaning that the code on the right side of the = symbol can throw that exception
  • A is Unit, 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 named SomeZioTrait, and a Scala trait 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 type ZIO[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 a def function OR as a val field. This gives people who extend your trait power in how they want to implement run.

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 a Throwable (and it could also be Exception or IOException).
  • Recall that this second parameter is ZIO’s E parameter, and it’s similar to Either’s error parameter.
  • Also because of the assumption that the code can throw an exception, I wrap the readLine code in ZIO.attempt. attempt is a ZIO factory method that you use when you literally attempt to do something that may fail. Because we’re imagining that readLine can fail, we use attempt 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.

Attribution

The “idea” icon in this video comes from this icons8.com link.