Introduction to Programming with ZIO Functional Effects

El artículo también está disponible en español.

This post was originally published on 03/02/2021 but has been updated due to ZIO 2.0. release, for freshness and accuracy.

Introduction

The functional programming paradigm is often avoided by developers around the world, who prefer to continue developing applications using an object-oriented programming paradigm, which is better known and more widely used. This rejection of functional programming happens either due to a simple lack of knowledge or because developers tend to think that it is something hard and only useful in academic fields.  This is probably because there are a lot of new concepts that are usually explained in a principled way, but are full of mathematical jargon. Nothing could be further from the truth: functional programming helps us to solve many of the problems that arise when developing software, especially programs that require high levels of asynchronicity and concurrency, and if it is properly explained it does not have to be something difficult to learn.

Scala language combines the best of both the functional and object-oriented programming worlds and provides complete interoperability with the vast Java ecosystem. In addition, within the Scala ecosystem, there are libraries such as ZIO, which allow developers to be highly productive and create modern, highly performant and concurrent applications, leveraging the power of functional programming – all without any huge barriers to entry.

In this article, I will explain the principles of functional programming, and then demonstrate how, with the help of Scala and ZIO, we can create applications to solve real-world problems. As an illustrative example, we will implement a hangman game.

Functional programming 101

What is functional programming?

Functional programming is a programming paradigm, where programs are a composition of pure functions. This represents a fundamental difference from object-oriented programming, where programs are sequences of statements, usually operating in a mutable state. These are organized into so-called functions, but they are not functions in the mathematical sense, because they do not meet some fundamental characteristics.

A pure function must be total

Firstly, a function must be total, this means that for each input that is provided to the function there must be a defined output. For example, the following function dividing two integers is not total:

def divide(a: Int, b: Int): Int = a / b

To answer why this function is not total, consider what will happen if we try to divide by zero:

divide(5, 0)

// java.lang.ArithmeticException: / by zero

The division by zero is undefined and Java handles this by throwing an exception. That means the divide function is not total because it does not return any output in the case where b = 0.

Some important things we can highlight here:

  • If we look at the signature of this function, we realize that it’s telling a lie: that for each pair of integers a and b, this function will always return another integer, which we have already seen is not true for all cases.
  • This means that every time we call the divide function in some part of our application we will have to be very careful, as we can never be completely sure that the function is going to return an integer.
  • If we forget to consider the unhandled case when calling divide, then at runtime our application will throw exceptions and stop working as expected. Unfortunately, the compiler is not able to do anything to help us to avoid this type of situation, this code will compile and we will only see the problems at runtime.

So how can we fix this problem? Let’s look at this alternative definition of the divide function:

def divide(a: Int, b: Int): Option[Int] =
  if (b != 0) Some(a / b) else None

In this case, the divide function returns Option[Int] instead of Int, so that when b = 0, the function returns None instead of throwing an exception. This is how we have transformed a partial function into a total function, and thanks to this we have some benefits:

  • The function’s signature is clearly communicating that some inputs are not being handled because it returns an Option[Int].
  • When we call the divide function in some part of our application, the compiler will force us to consider the case in which the result is not defined. If we don’t, it will be a compile-time error, which means the compiler can help us to avoid many bugs, and they will not appear at runtime.

A pure function must be deterministic and must depend only on its inputs

The second characteristic of a function is that it must be deterministic and must depend only on its inputs.  This means that for each input that is provided to the function, the same output must be returned, no matter how many times the function is called. For example, the following function for generating random integers is not deterministic:

def generateRandomInt(): Int = (new scala.util.Random).nextInt

To demonstrate why this function is not deterministic, let’s consider what happens the first time we call the function:

generateRandomInt() // Result: -272770531

And then, what happens when we call the function again:

generateRandomInt() // Result: 217937820

We get different results! Clearly, this function is not deterministic and its signature is misleading again, because it suggests that it does not depend on any input to produce an output, whereas in truth there is actually a hidden dependency on a scala.util.Random object. This may cause problems, because we can never really be sure how the generateRandomInt function is going to behave, making it difficult to test.

Now, let’s have a look at an alternative definition. For this, we’ll use a custom random number generator, based on an example from the Functional Programming in Scala book:

final case class RNG(seed: Long) {
  def nextInt: (Int, RNG) = {
    val newSeed = (seed * 0x5DEECE66DL + 0xBL) & 0xFFFFFFFFFFFFL
    val nextRNG = RNG(newSeed)
    val n       = (newSeed >>> 16).toInt
    (n, nextRNG)
  }
}

def generateRandomInt(random: RNG): (Int, RNG) = random.nextInt

This new version of the generateRandomInt function is deterministic: no matter how many times it is called, we will always get the same output for the same input and the signature now clearly states the dependency on the random variable. For example:

val random        = RNG(10)
val (n1, random1) = generateRandomInt(random) // n1 = 3847489, random1 = RNG(252149039181)
val (n2, random2) = generateRandomInt(random) // n2 = 3847489, random2 = RNG(252149039181)

If we want to generate a new integer, we must provide a different input:

val (n3, random3) = generateRandomInt(random2) // n3 = 1334288366, random3 = RNG(87443922374356)

A pure function must not have side effects

Finally, a function must not have any side effects. Some examples of side effects are the following:

  • memory mutations,
  • interactions with the outside world, such as:
    • printing messages to the console,
    • calling an external API,
    • querying a database.

This means that a pure function can only work with immutable values ​​and can only return an output for a corresponding input, nothing else.

For example, the following increment function is not pure because it works with a mutable variable a:

var a = 0;
def increment(inc: Int): Int = {
  a + = inc
  a
}

And the following function is not pure either because it prints a message in the console:

def add(a: Int, b: Int): Int = {
  println(s "Adding two integers: $ a and $ b")
  a + b
}

What are the differences between functional programming and object-oriented programming?

The following table summarizes the major differences between these two programming paradigms:

What are the benefits of functional programming?

For various reasons, functional programming may still seem complex to many. But, if we take a closer look at the benefits, we can change our way of thinking.

For starters, embracing this programming paradigm helps us to break each application down into smaller, simpler pieces that are reliable and easy to understand. This is because a functional source code is often more concise, predictable, and easier to test. But how can we ensure this?

  • Since pure functions do not rely on any state and instead depend only on their inputs, they are much easier to understand. That is, to understand what a function does, we do not need to look for other parts of the code that could affect its operation. This is known as local reasoning.
  • Their code tends to be more concise, which results in fewer bugs.
  • The process of testing and debugging becomes much easier with functions ​​that only receive input data and produce output.
  • Since pure functions are deterministic, applications behave more predictably.
  • Functional programming allows us to write correct parallel programs, since there is no possibility of having a mutable state, therefore it is impossible for typical concurrency problems to occur, such as race conditions.

Since Scala supports the functional programming paradigm, these benefits also apply to the language itself. As a result, more and more companies are using Scala, including giants such as  LinkedIn, Twitter, and Netflix.

But … real life applications need to run side effects!

Now we know that functional programming is about programming with pure functions, and that pure functions cannot produce side effects, several logical questions arise:

  • How can we then have an application written using functional programming,  which at the same time interacts with external services, such as databases or third-party APIs? Isn’t this somewhat contradictory?
  • Does this mean that functional programming cannot be used in real applications, but only in academic settings?

The answer to these questions is the following: Yes, we can use functional programming in real applications, and not just in academic settings. For our applications to be able to interact with external services, we can do the following: instead of writing functions that interact with the outside world, we write functions that describe interactions with the outside world, which are executed only at a specific point in our application, (usually called the end of the world) for example the main function.

If we think about this carefully, these descriptions of interactions with the outside world are simply immutable values that can serve as inputs and outputs of pure functions, and in this way we would not be violating a basic principle of functional programming: do not produce side effects.

What is this aforementioned end of the world? Well, the end of the world is simply a specific point in our application where the functional world ends, and where the descriptions of interactions with the outside world are being run, usually as late as possible, preferably at the very edge of our program which is its main function.  In this way, our entire application can be written following the functional style, but at the same time capable of performing useful tasks.

Now that we know all this, a new question arises: how can we write our applications in such a way that all our functions do not execute side effects, but only build descriptions of what we want to do?  And this is where a very powerful library comes in, one that can help us with this task: Introducing the ZIO library.

Introduction to the ZIO Library

What is the ZIO Library for?

ZIO is a library that allows us to build modern applications that are asynchronous, concurrent, resilient, efficient, easy to understand and to test, using the principles of functional programming.

Why do we say that ZIO allows us to build applications that are easy to understand and test? Because it helps us to build applications of any complexity incrementally,  through a combination of descriptions of interactions with the outside world. By the way, these descriptions are called functional effects.

Why do we say that ZIO allows us to build applications that are resilient? Because ZIO takes full advantage of the Scala type system, in such a way that it can catch more bugs at compile time, rather than at run time. This is great because, just by looking at the signature of a function, we can tell:

  • If it has external dependencies.
  • If it can fail or not, and also with what type of errors it can fail.
  • If it can finish successfully or not, and also what the type of data is that returns when finishing.

And finally, why do we say that ZIO allows us to build applications that are asynchronous and concurrent? Because ZIO gives us the superpowers to work with asynchronous and concurrent programming, using a fiber-based model, which is much more efficient than a thread-based model. We will not go into much detail about this particular aspect in this article, however it is worth mentioning that it is precisely in this area that ZIO shines, allowing us to build really performant applications.

The ZIO data type

The most important data type in the ZIO library (and also the basic building block of any application based on this library), is also called ZIO:

ZIO [-R, +E, +A]

The ZIO data type is a functional effect, which means that it is an immutable value that contains a description of a series of interactions with the outside world (database queries, calls to third-party APIs, etc.). A good mental model of the ZIO data type is the following:

R => Either[E, A]

This means that a ZIO effect:

  • Needs a context of type R to run (this context can be anything: a connection to a database, a REST client, a configuration object, etc.).
  • It may fail with an error of type E or it may complete successfully, returning a value of type A.

Common aliases for the ZIO data type

It’s worth mentioning that ZIO provides some type aliases for the ZIO data type which are very useful when it comes to representing some common use cases:

  • Task[+A] = ZIO[Any, Throwable, A]: This means a Task[A] is a ZIO effect that:
    • Doesn’t require an environment to run (that’s why the R type is replaced by Any, meaning the effect will run no matter what we provide to it as an environment)
    • Can fail with a Throwable
    • Can succeed with an A
  • UIO[+A] = ZIO[Any, Nothing, A]: This means a UIO[A] is a ZIO effect that:
    • Doesn’t require an environment to run.
    • Can’t fail
    • Can succeed with an A
  • RIO[-R, +A] = ZIO[R, Throwable, A]: This means a RIO[R, A] is a ZIO effect that:
    • Requires an environment R to run
    • Can fail with a Throwable
    • Can succeed with an A
  • IO[+E, +A] = ZIO[Any, E, A]: This means a IO[E, A] is a ZIO effect that:
    • Doesn’t require an environment to run.
    • Can fail with an E
    • Can succeed with an A
  • URIO[-R, +A] = ZIO[R, Nothing, A]: This means a URIO[R, A] is a ZIO effect that:
    • Requires an environment R to run
    • Can’t fail
    • Can succeed with an A

Implementing a Hangman game using ZIO

Next up, we will implement a Hangman game using ZIO, as an illustrative example of how we can develop purely functional applications in Scala with the help of this library.

For reference, you can see a Github repository with the complete code at this link.

Design and requirements

A Hangman game consists of taking a word at random and giving the player the possibility of guessing letters until the word is completed. The rules are as follows:

  • The player has 6 attempts to guess letters.
  • For each incorrect letter, one attempt is subtracted.
  • When the player runs out of attempts, he loses. If you guess all the letters, you win.

So, our implementation of the Hangman game should work as follows:

  • When starting the game, the player should be asked for his name, which obviously should not be empty. If so, an error message should be displayed and the name requested again.
  • The application must then randomly select, within a predefined dictionary of words, the word that the player must guess.
  • Then the initial state of the game must be displayed on the console, which basically consists of a gallows, a series of dashes that represent the number of letters in the word to be guessed and the letters that have already been tried by the player, which will obviously be zero at the beginning of the game.
  • Then, the player must be asked to guess a letter and write it on the console. Obviously, the character entered must be a valid letter, regardless of whether it is uppercase or lowercase:
    • If the character entered is invalid, an error message should be displayed, and a letter should be requested from the player again.
    • If the player has entered a valid letter but it does not appear in the word, the player loses an attempt, an appropriate message is displayed on the console, and the game status is updated, adding the letter recently attempted by the player to the attempts list, and drawing the head of the hanged man. By the way, all of the subsequent times that the player makes a mistake, the following parts of the body will be shown in order: the trunk, right arm, left arm, right leg and left leg of the hanged man.
    • If the user has entered a valid letter and it appears in the word, an appropriate message is displayed on the console and the game status is updated, adding the letter recently attempted by the user to the list of attempts, and discovering in the hidden word the places where the guessed letter appears.
  • The previous step is repeated until the user guesses the entire word or runs out of attempts.
    • If the player wins, a congratulatory message is displayed.
    • If the player loses, a message is displayed indicating what the word was to be guessed.

Creating the base structure of the application

We will define our application as a sbt project, the build.sbt file will contain the dependencies of our project: 

val scalaVer = "2.13.8"

val zioVersion = "2.0.0-RC6"

lazy val compileDependencies = Seq(
  "dev.zio" %% "zio" % zioVersion
) map (_ % Compile)

lazy val settings = Seq(
  name := "zio-hangman",
  version := "2.0.0",
  scalaVersion := scalaVer,
  libraryDependencies ++= compileDependencies
)

lazy val root = (project in file("."))
  .settings(settings)

As you can see, we will work with Scala 2.13.8 and with ZIO 2.0.0-RC6.

Creating the domain model, using a functional style

Firstly, we must define our domain model. For this we will define some classes within the file com/example/package.scala.

As a first step, we can define a Name class that represents the player’s name:

final case class Name(name: String)

As you can see, we define Name as a case class instead of simply defining it as a normal class. Using case classes gives us a lot of benefits, such as:

  • Immutability by default.
  • An apply method is generated automatically, which allows us to build objects without using the new keyword.
  • An unapply method is generated automatically,  which we can use in pattern matching expressions.
  • A copy method is generated automatically, which allows us to make copies of objects and update certain fields at the same time.

Also, we define the class as final so that it cannot be extended by other classes. On the other hand, we see the Name class simply encapsulates a String that represents the user’s name. We could leave this model as it is, however there’s a problem. It allows us to create Name objects with an empty String, which is unwanted. So, to prevent this, we can apply a functional design technique called smart constructor, which simply consists of defining a method in the companion object of the Name class that allows us to do the respective validations before creating an instance. So, we would have something like this:

final case class Name(name: String)
object Name {
  def make(name: String): Option[Name] =    if (name.nonEmpty) Some(Name(name)) else None
}

As you can see, the make method corresponds to our smart constructor, and it checks if the received String is empty or not. If it is not empty, it returns a new Name, but is encapsulated within a Some, and if it is empty it returns a None. The important thing to highlight here is that the make method is a pure function, because it is total, deterministic, the output only depends on the String input and it does not produce side effects.

Now, there are some things we can improve in our implementation. For example, it is still possible to directly call the Name constructor with empty Strings. To avoid this, we can make it private:

final case class private Name(name: String)
object Name {
  def make(name: String): Option[Name] =    if (name.nonEmpty) Some(Name(name)) else None
}

Thus, the only way to construct Name objects is by calling the Name.make method, at least that can be expected., However we are still able to construct Name objects with empty Strings by using the Name.apply method. Besides that, there is also another problem that we must solve, which is that thanks to the copy method that is automatically generated for case classes, after having created a valid Name object using Name.make, we could then generate an invalid object generating a copy with an empty String, and nothing would stop us from doing that. To avoid these issues, we can define the class as a sealed abstract instead of final, which means apply and copy won’t be generated:

sealed abstract case class Name private (name: String)
object Name {
  def make(name: String): Option[Name] =    if (name.nonEmpty) Some(new Name(name) {}) else 
None
}

In this way, we are completely sure that any Name object in our application will always be valid.

Similarly, we can define a case class called Guess, which represents any letter guessed by the player:

sealed abstract case class Guess private (char: Char)
object Guess {
  def make(str: String): Option[Guess] =
    Some(str.toList).collect {
      case c :: Nil if c.isLetter => new Guess(c.toLower) {}
    }}

As you can see, we have used the same technique of defining a smart constructor, which receives a String entered by the player, and checks if it only consists of one letter. If that is not the case (for example when the String entered by the player consists of several characters or when entering a number or a symbol) None is returned.

Then we can define another case class called Word, which represents the word to be guessed by the player:

sealed abstract case class Word private (word: String) {
  def contains(char: Char) = word.contains(char)
  val length: Int          = word.length
  def toList: List[Char]   = word.toList
  def toSet: Set[Char]     = word.toSet
}
object Word {
  def make(word: String): Option[Word] =
    if (word.nonEmpty && word.forall(_.isLetter)) Some(new Word(word.toLowerCase) {})
    else None
}

Again we have a smart constructor that verifies that a word is not empty and contains only letters, not numbers or symbols. Additionally, this class contains some useful methods (they are all pure functions, by the way):

  • To know if a word contains a certain character (contains).
  • To get the length of a word (length).
  • To get a List with the characters of the word (toList).
  • To get a Set with the characters of the word (toSet).

Next, we define another case class called State, which represents the internal state of the application, which includes:

  • The name of the player (name).
  • The letters guessed so far (guesses).
  • The word to guess (word)
sealed abstract case class State private (name: Name, guesses: Set[Guess], word: Word) {
  def failuresCount: Int            = (guesses.map(_.char) -- word.toSet).size
  def playerLost: Boolean           = failuresCount > 5
  def playerWon: Boolean            = (word.toSet -- guesses.map(_.char)).isEmpty
  def addGuess(guess: Guess): State = new State(name, guesses + guess, word) {}
}
object State {
  def initial(name: Name, word: Word): State = new State(name, Set.empty, word) {}
}

Similarly, we have a smart constructor State.initial, which allows us to instantiate the initial state of the game with the name of the player and the word that has to be guessed, obviously with an empty Set of guessed letters. On the other hand, State includes some useful methods:

  • To get player’s number of failures (failuresCount)
  • To know if the player lost (playerLost)
  • To know if the player won (playerWonGuess)
  • To add a letter to the letter Set (addGuess)

Finally, we define a GuessResult model that represents the result obtained by the player after guessing a letter. This result can be one of the following:

  • The player won, because he guessed the last missing letter.
  • The player lost, because he used his last remaining attempt.
  • The player correctly guessed a letter, although there are still missing letters to win.
  • The player incorrectly guessed a letter, although he still has more attempts.
  • The player repeated a letter that he had previously guessed, therefore the state of the game does not change.

We can represent this with an enumeration, as follows:

sealed trait GuessResult
object GuessResult {
  case object Won       extends GuessResult
  case object Lost      extends GuessResult
  case object Correct   extends GuessResult
  case object Incorrect extends GuessResult
  case object Unchanged extends GuessResult
}

In this case an enumeration makes sense since GuessResult can only have one possible value from those mentioned in the list of options. Some important details here:

  • We define an enumeration as a sealed trait.
  • The word sealed is important, as it ensures that all classes that can extend GuessResult are in the same file (package.scala) and that no other class outside of this file will be able to extend GuessResult.
  • The word sealed is also important because in this way the compiler can help us when we use pattern matching on instances of GuessResult, warning us if we have not considered all possible options.
  • The possible values ​​of GuessResult are defined within its companion object.

Creating the application skeleton

Now that we have defined the domain model of our application, we can begin with the implementation in the file com/example/Hangman.scala. Inside this file we will create an object that implements the ZIOAppDefault trait, like this:

import zio._

object Hangman extends ZIOAppDefault {
  def run = ???
}

With just this little piece of code we can learn several things:

  • To work with ZIO, we only need to include import zio._, which will give us, among other things, access to the ZIO data type.
  • Every ZIO application must implement the ZIOAppDefault trait, instead of the App trait from the standard library (To be more precise, every ZIO application must implement the ZIOApp trait. However ZIOAppDefault, which extends ZIOApp,  is more convenient because it provides some default values for us).

The ZIOAppDefault trait requires that we implement a run method, which is the application’s entry point. This method should simply return a ZIO functional effect, which we already know is only a description of what our application should do. This description will be translated by the ZIO Runtime, at the time of executing the application, in real interactions with the outside world, that is, side effects (the interesting thing about this is that we as developers do not need to worry about how this happens, ZIO takes care of all that for us). If the provided effect fails for any reason, the cause will be logged, and the exit code of the application will be non-zero. Otherwise, the exit code of the application will be zero. Therefore, the run method would be the end of the functional world for our application, and we will leave it unimplemented for now.

Functionality to obtain the name of the player by console

Now that we have the basic skeleton of our application, firstly we need to write functionality to obtain the name of the player by the console, for this we will write an auxiliary function, inside the Hangman object, which allows us to print any message and then requests a text from the player:

def getUserInput(message: String): IO[IOException, String] = {
  Console.printLine(message)
  Console.readLine
}

Let’s see this function in more detail:

  • To print the provided message on the console, we use the Console.printLine function included in the zio package. This function returns an effect of the type ZIO[Any, IOException, Unit], equivalent to IO[IOException, Unit], which means that it is an effect that:
    • Doesn’t require any user-defined environment to be executed.
    • Could fail with an IOException
    • Returns a value of type Unit
  • Then, to ask the user to enter a text, we use the Console.readLine function also included in the zio package. This function returns an effect of type ZIO[Any, IOException, String], equivalent to IO[IOException, String], which means that it is an effect that:
    • Doesn’t require any user-defined environment to be executed
    • Could fail with an error of type IOException
    • Returns a value of type String
  • There is something very important to notice here about how ZIO 2.0 works compared to ZIO 1.0. In ZIO 1.0, the type signature of this method would have been:
def getUserInput(message: String): ZIO[Console, IOException, String]

Meaning that getUserInput would have returned a ZIO effect which required the Console standard module provided by ZIO. This has been simplified in ZIO 2.0, so that when we use just ZIO standard modules (such as Console or Random), they don’t appear in the type signature anymore, and this will make our lives a lot easier. Just when we use user-defined modules they will be reflected in the type signature, and because in this example we are not defining our own modules, the environment type will always be Any.

And that’s it! Well… there is actually a small problem, and that is that if we called this function from the run method, a message would never be displayed in the console and it would go directly to request a text from the user. What is missing then, if we call Console.printLine first and then Console.readLine? The problem is that both Console.printLine and Console.readLine return simple descriptions of interactions with the outside world (called functional effects), and if we look closely: are we really doing something with the functional effect returned by Console.printLine? The answer is no, we are simply discarding it, and the only effect that is returned by our getUserInput function is the effect returned by Console.readLine. It’s more or less as if we had a function like this:

def foo (): Int = {
  4
  3
}

Are we doing something with the 4 in that function? Nothing! It’s just as if it’s not there, and the same thing happens in our getUserInput function, it’s as if the Console.printLine call wasn’t there.

Therefore, we have to modify the getUserInput function so that the effect returned by Console.printLine is actually used:

def getUserInput(message: String): IO[IOException, String] =
  Console.printLine(message).flatMap(_ => Console.readLine)

What this new version does is to return an effect that sequentially combines the effects returned by Console.printLine and Console.readLine, using the ZIO#flatMap operator, which receives a function where:

  • The input is the result of the first effect (in this case Console.printLine, which returns Unit).
  • Returns a new effect to be executed, in this case Console.readLine.
  • Something important about how ZIO#flatMap works, is that if the first effect fails, the second effect is not executed.

In this way, we are no longer discarding the effect produced by Console.printLine.

Now, because the ZIO data type also offers a ZIO#map method, we can write getUserInput using a for-comprehension, which helps us to visualize the code in a way that looks more like a typical imperative code:

def getUserInput(message: String): IO[IOException, String] =
  for {
    _     <- Console.printLine(message)
    input <- Console.readLine
  } yield input

This implementation works perfectly, however we can write it differently:

def getUserInput(message: String): IO[IOException, String] =
  Console.printLine(message) <*> Console.readLine

In this case, we are using another operator to combine two effects sequentially, and it is the <*> operator (which by the way is equivalent to the ZIO#zip method). This operator, like ZIO#flatMap, combines the results of two effects, with the difference that the second effect does not need the result of the first to be executed. The result of <*> is an effect whose return value is a tuple, which in this case would be (Unit, String). However, notice the following: if the success type of getUserInput is String, why is this implementation working at all? Because the success type when calling the <*> operator would be (Unit, String), this shouldn’t even compile! So why is it working? The answer: This is another ZIO 2.0 simplification! In ZIO 1.0, compilation would have failed, and we would have needed to do something like this:

def getUserInput(message: String): ZIO[Console, IOException, String] =
  (Console.printLine(message) <*> Console.readLine).map(_._2)

We needed to call ZIO#map, which is a method that allows us to transform the result of an effect, in this case we would be obtaining the second component of the tuple returned by <*>.

But, in ZIO 2.0, we have Compositional Zips, which basically means that if the success type of a zip operation contains a Unit in one of the members of the tuple, the Unit is automatically discarded. Then, instead of (Unit, String), we have just String.

It’s important to mention that, instead of using the <*> operator, we could have also used the *> operator (equivalent to the ZIO#zipRight method) , which always discards the result of the left-side computation, even if its result type is not Unit:

def getUserInput(message: String): IO[IOException, String] =
  Console.printLine(message) *> Console.readLine

And finally, the most condensed version of getUserInput would be the following:

def getUserInput(message: String): IO[IOException, String] =  
  Console.readLine(message)

And well, now that we have a function to get a text from the player, we can implement a functional effect to specifically get their name:

lazy val getName: IO[IOException, Name] =
  for {
    input <- getUserInput("What's your name? ")
    name <- Name.make(input) match {
            case Some(name) => ZIO.succeed(name)
            case None =>               Console.printLine("Invalid input. Please try again...") <*> getName
          }
  } yield name

As you can see:

  • First the player is asked to enter his name.
  • Then an attempt is made to build a Name object, using the method Name.make that we defined earlier.
    • If the entered name is valid (that is, it is not empty) we return an effect that ends successfully with the corresponding name, using the ZIO.succeed method.
    • Otherwise, we display an error message on the screen (using Console.printLine) and then request the name again, calling getName recursively. This means that the getName effect will run as many times as necessary, as long as the player’s name is invalid.

And that’s it! However, we can write an equivalent version:

lazy val getName: IO[IOException, Name] =
  for {
    input <- getUserInput("What's your name?")
    name  <- ZIO.fromOption(Name.make(input)) <> (Console.printLine("Invalid input. Please try again...") <*> getName)
  } yield name

In this version, the result of calling Name.make, which is of type Option, is converted into a ZIO effect, using the ZIO.fromOption method, which returns an effect that ends successfully if the given Option is a Some, and an effect that fails if the given Option is None. Then we are using a new <> operator (which is equivalent to the ZIO#orElse method), which also allows us to combine two effects sequentially, but in a somewhat different way than <*>. The logic of <> is as follows:

  • If the first effect is successful (in this case: if the player’s name is valid), the second effect is not executed.
  • If the first effect fails (in this case: if the player’s name is invalid), the second effect is executed (in this case: an error message is displayed and then getName is called again).

As you can see, this new version of getName is somewhat more concise. There’s one more simplification we can make, though:

lazy val getName: IO[IOException, Name] =
  for {
    input <- getUserInput("What's your name? ")
    name  <- ZIO.from(Name.make(input)) <> (Console.printLine("Invalid input. Please try again...") <*> getName)
  } yield name

Instead of ZIO.fromOption, we can use ZIO.from, which constructs a ZIO value of the appropriate type for the specified input, for example:

  • An Option
  • Other types such as Either or Try (we could use ZIO.fromEither and ZIO.fromTry as well)

Functionality to choose a word at random from the dictionary

Let’s now see how to implement the functionality to randomly choose which word the player has to guess. For this we have a words dictionary, which is simply a list of words located in the file com/example/package.scala.

The implementation is as follows:

lazy val chooseWord: UIO[Word] =
  for {
    index <- Random.nextIntBounded(words.length)
    word  <- ZIO.from(words.lift(index).flatMap(Word.make)).orDieWith(_ => new Error("Boom!"))
  } yield word

As you can see, we are using the Random.nextIntBounded function of the zio package. This function returns an effect that generates random integers between 0 and a given limit, in this case the limit is the length of the dictionary. Once we have the random integer, we obtain the corresponding word from the dictionary, with the expression words.lift(index).flatMap(Word.make), which returns an Option[Word], which is converted to a ZIO effect using the ZIO.from method, this effect:

  • Ends successfully if the word is found in the dictionary for a certain index, and if this word is not empty (condition verified by Word.make).
  • Otherwise, it fails.

If we think about it carefully, can there be any case where getting a word from the dictionary fails? Not really, because the chooseWord effect will never try to get a word whose index is outside the range of the dictionary length, and on the other hand all the words in the dictionary are predefined and not empty. Therefore, we can rule out the wrong case without any problem, using the ZIO#orDieWith method, which returns a new effect that cannot fail and, if it does fail, that would mean that there is some serious, non-recoverable defect in our application (for example that some of our predefined words are empty when they should not be) and therefore it should fail immediately with the provided exception.

At the end, chooseWord is an effect with type UIO[Word], which means that:

  • Doesn’t require any user-defined environment to be executed
  • It cannot fail
  • It successfully terminates with an object of type Word

Functionality to show the game state by console

The next thing we need to do is to implement the functionality to show the game state by console:

def renderState(state: State): IO[IOException, Unit] = {

  /*
      --------
      |      |
      |      0
      |     \|/
      |      |
      |     / \
      -

      f     n  c  t  o
      -  -  -  -  -  -  -
      Guesses: a, z, y, x
  */
  val hangman = ZIO.attempt(hangmanStages(state.failuresCount)).orDie
  val word =
    state.word.toList
      .map(c => if (state.guesses.map(_.char).contains(c)) s" $c " else "   ")
      .mkString

  val line    = List.fill(state.word.length)(" - ").mkString
  val guesses = s" Guesses: ${state.guesses.map(_.char).mkString(", ")}"

  hangman.flatMap { hangman =>
    Console.printLine {
      s"""
      #$hangman
      #
      #$word
      #$line
      #
      #$guesses
      #
      #""".stripMargin('#')
    }
  }
}

We will not go into too much detail about how this function is implemented, rather we will focus on a single line, which is perhaps the most interesting one:

val hangman = ZIO.attempt(hangmanStages(state.failuresCount)).orDie

We can see that what this line does, is to first of all, select which figure should be shown to represent the hangman, which is different according to the number of failures the player has had (the file com/example/package.scala contains a hangmanStates variable that consists of a list with the six possible figures). For example:

  • hangmanStates(0) contains the hangman drawing for failuresCount = 0, that is, it shows only the drawing of the gallows without the hanged man
  • hangmanStates(1) contains the hanged man drawing for failuresCount = 1, that is, it shows the drawing of the gallows and the head of the hanged man.

Now, the expression hangmanStages(state.failuresCount) is not purely functional because it could throw an exception if state.failuresCount is greater than 6. So, since we are working with functional programming, we cannot allow our code to produce side effects, such as throwing exceptions, that is why we have encapsulated the previous expression inside ZIO.attempt, which allows us to build a functional effect from an expression that may throw exceptions. By the way, the result of calling ZIO.attempt is an effect that can fail with a Throwable, but according to the design of our application we hope that there will never actually be a failure when trying to get the hanged man drawing (since state.failuresCount should never be greater than 6). Therefore we can rule out the wrong case by calling the ZIO#orDie method, which returns an effect that never fails (we already know that if there were any failure, it would actually be a defect and our application should fail immediately). By the way, ZIO#orDie is very similar to ZIO#orDieWith, but it can only be used with effects that fail with a Throwable.

Functionality to obtain a letter from the player

The functionality to obtain a letter from the player is very similar to how we obtain the name, therefore we will not go into too much detail:

lazy val getGuess: IO[IOException, Guess] =
  for {
    input <- getUserInput("What's your next guess? ")
    guess <- ZIO.from(Guess.make(input)) <> (Console.printLine("Invalid input. Please try again...") <*> getGuess)
  } yield guess

Functionality to analyze a letter entered by the player

This functionality is very simple, all it does is to analyze the previous state and the state after a player’s attempt, to see if the player wins, loses, has correctly guessed a letter but has not yet won the game, has incorrectly guessed a letter but has not yet lost the game, or if he has retried a letter that was previously tried:

def analyzeNewGuess(oldState: State, newState: State, guess: Guess): GuessResult =
  if (oldState.guesses.contains(guess)) GuessResult.Unchanged
  else if (newState.playerWon) GuessResult.Won
  else if (newState.playerLost) GuessResult.Lost
  else if (oldState.word.contains(guess.char)) GuessResult.Correct
  else GuessResult.Incorrect

Game loop implementation

The game loop implementation uses the functionalities that we defined earlier:

def gameLoop(oldState: State): IO[IOException, Unit] =
  for {
    guess       <- renderState(oldState) <*> getGuess
    newState    = oldState.addGuess(guess)
    guessResult = analyzeNewGuess(oldState, newState, guess)
    _ <- guessResult match {
          case GuessResult.Won =>
            Console.printLine(s"Congratulations ${newState.name.name}! You won!") <*> renderState(newState)
          case GuessResult.Lost =>
            Console.printLine(s"Sorry ${newState.name.name}! You Lost! Word was: ${newState.word.word}") <*>
              renderState(newState)
          case GuessResult.Correct =>
            Console.printLine(s"Good guess, ${newState.name.name}!") <*> gameLoop(newState)
          case GuessResult.Incorrect =>
            Console.printLine(s"Bad guess, ${newState.name.name}!") <*> gameLoop(newState)
          case GuessResult.Unchanged =>
            Console.printLine(s"${newState.name.name}, You've already tried that letter!") <*> gameLoop(newState)
        }
  } yield ()

As you can see, what the gameLoop function does is the following:

  • Displays the status of the game by console and gets a letter from the player.
  • The state of the game is updated with the new letter guessed by the player.
  • The new letter guessed by the player is analyzed and:
    • If the player won, a congratulatory message is displayed and the game status is displayed for the last time.
    • If the player lost, a message is displayed indicating the word to guess and the state of the game is displayed for the last time.
    • If the player has guessed a letter correctly but has not yet won the game, a message is displayed stating that they guessed correctly and gameLoop is called again, with the updated state of the game.
    • If the player has guessed a letter incorrectly but has not yet lost the game, a message is displayed stating that they guessed incorrectly and gameLoop is called again, with the updated state of the game.
    • If the player tries a letter that they had guessed before, a message is displayed and gameLoop is called again, with the updated state of the game.

At the end, gameLoop returns a ZIO effect that doesn’t depend on any user-defined module, it can fail with an IOException or end successfully with a Unit value.

Putting all the pieces together

Finally, we have all the pieces of our application, and now the only thing that remains for us to do is to put them all together and call them from the run method which, as we have already mentioned , is the entry point of any ZIO-based application:

val run: IO[IOException, Unit] =
  for {
    name <- Console.printLine("Welcome to ZIO Hangman!") <*> getName
    word <- chooseWord
    _    <- gameLoop(State.initial(name, word))
  } yield ()

We can see the logic is very simple:

  • A welcome message is printed and the player’s name is requested.
  • A word is chosen at random for the player to guess.
  • The game loop runs.

And that’s it! We have finished the implementation of a Hangman game, using a purely functional programming style, with the help of the ZIO library.

Conclusions

In this article we have been able to see how, thanks to libraries such as ZIO, we can implement complete applications using the functional programming paradigm, just by writing descriptions of interactions with the outside world (called functional effects) that can be combined with each other to form more complex descriptions. We have seen that ZIO allows us to create, combine and transform functional effects with each other in various ways, although we certainly have not seen all the possibilities that ZIO offers.  However, I hope this will serve as a boost to continue exploring the fascinating ecosystem that is being built around this library.

Something important that we have not done in this article is to write unit tests for our application, so if you want to know how to write unit tests, in a purely functional way, using the ZIO Test library, you can take a look at this article in the Scalac Blog.

Finally, if you want to learn more about ZIO, you can take a look at the following resources:

Read also:

References

Authors

Jorge Vasquez

I'm a software developer, mostly focused on the backend. I've had the chance to work with several technologies and programming languages across different industries, such as Telco, AdTech, and Online Education. I'm always looking to improve my skills, finding new and better ways to solve business problems. I love functional programming, I'm convinced it can help to make better software, and I'm excited about new libraries like ZIO that are making Scala FP more accessible to developers. Besides programming, I enjoy photography, and I'm trying to get better at it.

Latest Blogposts

28.06.2022 / By  Jorge Vasquez

A Prelude of Purity: Scaling Back ZIO

ZIO World is the leading annual global ZIO-based event created by Ziverge. The event aims to inspire new trends, promote innovation, and reveal significant developments across the ZIO ecosystem. ZIO World invites developers to share their expertise in using ZIO. This year, ZIO World hosted Scalac developer and ZIO contributor Jorge Vásquez. His presentation focused on […]

20.06.2022 / By  John Jimenez , Francois Armand

Functional Programming vs OOP

As a young, bright-eyed, bushy-tailed engineer starting my career at NASA in the 90s, I was fortunate enough to develop engineering-oriented software that modeled the different parts of the International Space Station. The million-line codebase was based on objects. Almost every part of the space station was represented as an object, from the overall segments […]

14.06.2022 / By  Łukasz Gajowy

OpenTelemetry from a bird’s eye view: a few noteworthy parts of the project

OpenTelemetry provides you with a set of tools, integrations, APIs, and SDKs in different languages to more easily increase the observability of your application. We figured that, since we’re working on an OpenTelemetry agent extension called Mesmer, we could show you the project from a developer’s perspective and point you to the parts that could […]

Need a successful project?

Estimate project