This is a study note of ZIO: a library for asynchronous and concurrent programming based on pure functional programming. This part covers the getting started and motivation of ZIO. It is based on Getting Started, the background, zio history and the video of Magic Tricks with Functional Effects.

1 Getting Started

Include ZIO in build.sbt file as libraryDependencies += "dev.zio" %% "zio" % "1.0.0-RC18-2".

The application can extend zio.App, which provides a runtime. The code is as following:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import zio.App
import zio.console.{getStrLn, putStrLn}

object MyApp extends App {
  def run(args: List[String]) = {
    myAppLogic.fold(_ => 1, _ => 0)
  }

  val myAppLogic = for {
    _ <- putStrLn("Hello! What is your name? ")
    name <- getStrLn
    _ <- putStrLn(s"Welcome ${name} to ZIO")
  } yield ()
}

The run method should return an unexceptional ZIO value. An unexceptional ZIO value is a ZIO value that has all its errors handled. The fold method handles both error and succss values to 1 and 0 correspondingly. The zio.console module provides IO methods such as putStrLn and getStrLn.

If you integrate ZIO into an existing application, create a runtime and run ZIO code.

1
2
3
4
5
6
7
8
9
import zio.{Runtime, Task}

object MyApp {
  def main(args: Array[String]) = {
    val effect = Task(println("Hi"))
    val runtime = Runtime.default
    runtime.unsafeRun(effect)
  }
}

The Task(println("Hi)) creates a ZIO effect that wraps () => println("Hi"). ZIO runtime executes this partial function in a fiber.

2 Background

Procedural functions have three problems:

  • partial: throw exception in some input
  • non-deterministic: may return different values for the same input
  • impure: perform side effects that mutate data or interact with the external world

A pure function is total, deterministic and pure. Pure functions are easier to understand, easier to test, easier to refactor, and easier to abstract over. Functional programs construct and return data structures that describe interaction with the real world. Immutable data structures that model side-effects are called functional effects or simply “effects” in ZIO. ZIO let you build programs using the functional/lazy approach: the idea is to describe all side-effects using an immutable data structure and make the program a pure functional program. You can refactor, abstract and test the program easily because it is easy to understand the pure functional code. To run it, interprete the data structure and execute.

ZIO introduces a new way to do functional programming in Scala with following features:

  • improved operation names, no FP jargons such as monda, pure etc.
  • async/concurrent constructs: fiber, STM, Ref
  • depency declarations
  • resource management
  • testability
  • (all is done with) no type classes, no implicits, no higher-kinded types, and no category theory

3 An Example

A simple console program can be modeled by the following types:

1
2
3
4
sealed trait Console[+A]
final case class Return[A](value: () => A) extends Console[A]
final case class PrintLine[A](line: String, rest: Console[A]) extends Console[A]
final case class ReadLine[A](rest: String => Console[A]) extends Console[A]

You can think that the Console[+A] is a program that returns a value of type [A]. The Return case class describes a side-effect that takes no input and return a value of type A. It represents the completion of the program and should be the last element of the program. The PrintLine has a line to be printed and the rest of the program. The ReadLine reads a line and use it as an input to the rest of the program. Following is an interpret to execute a program consisting of the three case classes.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
object Console {
  def interpret[A](program: Console[A]): A = program match {
    case Return(value) => value()
    case PrintLine(line, rest) => {
      println(line)
      interpret(rest)
    }
    case ReadLine(rest) => {
      val line = scala.io.StdIn.readLine()
      interpret(rest(line))
    }
  }
}

The following is an execution of a sample program.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import Console.interpret

object MyApp {
  def main(args: Array[String]) = {

    val program1: Console[Unit] =
      PrintLine(
        "Hello, what is your name? ",
        ReadLine(name => PrintLine(s"hi ${name}", Return(() => ())))
      )

    interpret(program1)
  }
}

The functional effect program is a recursive data structure that ends with Return. The return could use a by-name parameter to deliver program completion status. Because a case class cannot have a by-name parameter, the example uses a functional parameter () => A for simplicity.

4 The for Expression

A better way to write the functional effect program is using the for expression. It means that you need to define flatMap and map method for Console[A]. Meanwhile, each generator expression in the for should create a Console[A] instance. First, create some help methods and then define an implicit class that adds flatMap and map method to the Console[A].

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
def succeed[A](a: => A): Console[A] = Return(() => a)
def printLine(line: String): Console[Unit] = PrintLine(line, succeed(()))
def readLine: Console[String] = ReadLine(line => succeed(line))

implicit class ConsoleFor[+A](self: Console[A]) {
  def flatMap[B](g: A => Console[B]): Console[B] = self match {
    case Return(value)         => g(value())
    case PrintLine(line, rest) => PrintLine(line, rest.flatMap(g))
    case ReadLine(rest)        => ReadLine(line => rest(line).flatMap(g))
  }

  def map[B](f: A => B): Console[B] = {
    flatMap(a => Return(() => f(a)))
  }
}

Execute a program with the following code:

1
2
3
4
5
6
7
val program2 = for {
  _ <- printLine("Hello, what's your name? ")
  name <- readLine
  _ <- printLine(s"Hi ${name}")
} yield ()

interpret(program2)

The above code explains the “theory” behind ZIO library: writing program as a functional effect to represent the logic, then interprete and execute the code. The function effect is an immutable, type-safe, tree-like data structure that models side effects. The benefits are equational reasoning, composability and type safety, as results of functioanl programming.

5 The Toy ZIO

Functional effects interact with the outside world in a way that is composable, type-safe and testable. It describes the interaction as immutable values and executed at the edage of the program.

A value of type ZIO[R, E, A] is an effectful versoin of R => Either[E, A]. The mental model of ZIO is as the following, based on the video of Magic Tricks with Functional Effects:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
object ZIOToy {
  def succeed[A](a: A): ZIOToy[Any, Nothing, A] = ZIOToy(_ => Right(a))
  def fail[E](e: E): ZIOToy[Any, E, Nothing] = ZIOToy(_ => Left(e))

  // access the environment
  def environment[R]: ZIOToy[R, Nothing, R] = ZIOToy(r => Right(r))

  // the core of functional effect
  def effect[A](action: => A): ZIOToy[Any, Throwable, A] = ZIOToy { _ =>
    try Right(action)
    catch {
      case t: Throwable => Left(t)
    }
  }

  // throw an exception and die
  def die(t: Throwable): ZIOToy[Any, Nothing, Nothing] = ZIOToy { _ => throw t }
}

final case class ZIOToy[-R, +E, +A](run: R => Either[E, A]) { self =>

  final def map[B](f: A => B): ZIOToy[R, E, B] = ZIOToy(r => run(r).map(f))

  // compose nested effects using state monad
  // both the current ZIOToy and the f use the same r
  final def flatMap[R1 <: R, E1 >: E, B](
      f: A => ZIOToy[R1, E1, B]
  ): ZIOToy[R1, E1, B] =
    ZIOToy(r => run(r).flatMap(a => f(a).run(r)))

  // provides the requirement R to fulfill the requirement
  final def provide(r: R): ZIOToy[Any, E, A] = ZIOToy(_ => run(r))

  // error recoverying by moving the error to the success value
  final def either: ZIOToy[R, Nothing, Either[E, A]] =
    ZIOToy(r => Right(run(r)))

  // combine results of two
  def zip[R1 <: R, E1 >: E, B](
      that: => ZIOToy[R1, E1, B]
  ): ZIOToy[R1, E1, (A, B)] =
    self.flatMap(a => that map (b => (a, b)))

  // zipLeft and zipRight
  def <*[R1 <: R, E1 >: E, B](that: => ZIOToy[R1, E1, B]): ZIOToy[R1, E1, A] =
    (self zip that) map (_._1)
  def *>[R1 <: R, E1 >: E, B](that: => ZIOToy[R1, E1, B]): ZIOToy[R1, E1, B] =
    (self zip that) map (_._2)
}

object Main extends App {

  def putStrLn(line: String) = ZIOToy.effect(println(line))
  val getStrLn = ZIOToy.effect(scala.io.StdIn.readLine())

  val program = for {
    _ <- putStrLn("Please input your name: ")
    name <- getStrLn
    _ <- putStrLn(("Hi " + name))
  } yield name

  program.run()
}

An instance of ZIO[R, E, A] wraps a function that takes an R input and produces a value of type Either[E, A]. ZIO effects are not actually functions because they model complex effects like asynchronous and concurrent effects. As shown in the flatMap method, ZIO is implemented as a state monad. In the flatMap composition, the second (inside) effect can use the result of the first (enclosing) effect in the happy path. map and flatMap model the essence of the imperative programming composition that executes functions sequentially: one for pure function (A => B) and one for effectful function (A => ZIO[R, E, B]).

6 Other Effects and Magic Tricks

The above only shows the success effects, failure effects and synchronous effects. There are asynchronous effects, concurrent effects, cancelation effects, resource effects, paralle effects and dependency effects. The combination of these effects brings many magic tricks such as:

  • orElse: try an alternative when the first fails
  • forever: repeat forever or fail
  • eventually: repeat until sucess
  • flip: switch error and value and brings many possibilities, for example, can be used to implement mapError, implement eventually using forever, implement a fall back plan
  • fromTry, fromOption, fromEither, fromFuture to unify different Scala effects.

Other tricks include interruptable and auto-collectable fibers, auto-retry and transactional STM that involves multiple operations and multiple shared data. ZIO gives rich debuggable messages and fiber dumps.

The painpoints of ZIO include combination of errors, currently only super type that may lose information. May use union type of Scala 3. Another pain point is the environment combination like A with B for proxies, Scala 3 may have keyword support like Kotlin.