ZIO is a type-safe, composable library for asynchronous and concurrent programming in Scala (from: The ZIO github). The library copes with functional IO, like many Functional Programming libraries do. The added value of ZIO is that the ZIO[R, E, A] type-constructor (the main IO monad of the library) acts as an IO monad, an error handling monad, and a reader monad. A functional programming style often needs a combination of these three types to cope with the most common problems when creating an application:

  • performing side effects (getting the A)

  • coping with errors (handling E)

  • supplying dependencies (providing R)

This blogpost will show you how to cope with the R part of a ZIO[R, E, A]: the Environment

ZIO provided Environment

A very simple application is shown here. It just prints something to the console when it runs. To print to the console, ZIO provides the Console environment. Important here is that Main extends zio.App. App provides a DefaultRuntime, which contains a Console for our Main to use. It also provides the signature of the main function which is started on boot:

Listing 1. ZEnv is the provided runtime that has a Console
def run(args: List[String]): ZIO[ZEnv, Nothing, Int]

Nice! This means we do not need to provide any other dependencies to our application:

Listing 2. the simplest main application. N.B. our program is simply logic for now, this will be useful for clarity later on.
import zio._
import zio.console.Console

object Main extends App {
  override def run(args: List[String]): ZIO[Console, Nothing, Int] = program

  val logic = (for {
    _ <- console.putStrLn(s"I'm running!")
  } yield 0)
    .catchAll(e => console.putStrLn(s"Application run failed $e").as(1))

  private val program = logic
}

A custom Environment

Sometimes, though, we would like to provide a custom dependency to our application. To keep it very, very general for now, let’s provide our runtime with an X! X can be anything of course, but providing an X will show the general scaffold of an Environment, or a Module.

Listing 3. the XModule
import zio._

trait XModule {
  val xModule: XModule.Service[Any]
}

object XModule {

  case class X(x: String = "x", y: String = "y", z: String = "z")

  trait Service[R] {
    def x: ZIO[R, Nothing, X]
  }

  trait Live extends XModule {
    val xInstance: X

    val xModule: XModule.Service[Any] = new Service[Any] {
      override def x: ZIO[Any, Nothing, X] = UIO(xInstance)
    }
  }

  object factory extends XModule.Service[XModule] {
    override def x: ZIO[XModule, Nothing, X] = ZIO.environment[XModule].flatMap(_.xModule.x)
  }
}

To give shape to X, we made it a case class. For convenience, we created an accessor called factory to hide the logic of accessing the Environment and getting to x. An instance of X can be retrieved like this:

Listing 4. using X
val moduleX: XModule = ???
val x: UIO[X] = moduleX.xModule.x

We wrapped the instance in a UIO, which is a ZIO[Any, Nothing, A] (indicating it can run on any environment, and can never fail). Later we see why. Let’s provide our program with an XModule on top of the Console, and use it to get an X:

Listing 5. providing our application with an XModule
import com.jdriven.cvdsteeg.zio.XModule.X
import zio._
import zio.console.Console

object Main extends App {
  def run(args: List[String]): ZIO[ZEnv, Nothing, Int] = program

  val logic: ZIO[Console with XModule, Nothing, Int] = (for {
    _   <- console.putStrLn(s"I'm running!")
    x   <- XModule.factory.x
    _   <- console.putStrLn(s"I've got an $x!")
  } yield 0)
    .catchAll(e => console.putStrLn(s"Application run failed $e").as(1))

  private val program = logic
    .provideSome[Console] { c =>
      new Console with XModule.Live {
        override val console: Console.Service[Any] = c.console
        override val xInstance: X                  = X()
      }
    }
}

To make the code compile, we need to provide the ZIO[R, E, A] that is returned by logic with our custom module, because zio.App does not provide us with an XModule. Therefore, we can use provideSome to provide the parts of the R that are not already provided, but are needed to run the ZIO[R, E ,A]. If we can create an Environment that is a complete R, we can use provide here. In short, provideSome(XModule) can reduce a ZIO[Console with XModule, Nothing, Int] to just a ZIO[Console, Nothing, Int]

Now our application can use an X! It doesn’t matter what instance of the module we provide, as long as it has the functionality of the XModule trait. Therefore, the recommended way of creating a custom environment is by creating a trait. Here it is also shown why it is convenient to let a module function return a ZIO, it can be incorporated into the for-comprehension we’re already using. We ask ZIO for an environment of a certain type (XModule), and we can use it.

A useful example

We’ve seen the generic way of creating a module, let’s apply this structure to a more useful example: a module that will parse our application configuration for us. Since the actual implementation doesn’t really matter, we’re going to let PureConfig, a widely used functional config parser library, parse the default application.conf file into our custom case class.

The config file looks like this:

Listing 6. application.conf
app-name = "zio-environment-example"

PureConfig will parse this by default into a case class with a field appName: String.

Listing 7. Configuration case class
case class Configuration(appName: String)

The full ConfigurationModule is shown here:

Listing 8. ConfigurationModule
import pureconfig._
import pureconfig.generic.auto._
import zio._

trait ConfigurationModule {
  val configurationModule: ConfigurationModule.Service[Any]
}

object ConfigurationModule {

  case class ConfigurationError(message: String) extends RuntimeException(message)
  case class Configuration(appName: String)

  trait Service[R] {
    def configuration: ZIO[R, Throwable, Configuration]
  }

  trait Live extends ConfigurationModule {
    val configurationModule: ConfigurationModule.Service[Any] = new Service[Any] {
      override def configuration: Task[Configuration] =
        ZIO
          .fromEither(ConfigSource.default.load[Configuration])
          .mapError(e => ConfigurationError(e.toList.mkString(", ")))
    }
  }

  object factory extends ConfigurationModule.Service[ConfigurationModule] {
    override def configuration: ZIO[ConfigurationModule, Throwable, Configuration] = ZIO.accessM[ConfigurationModule](_.configurationModule.configuration)
  }
}

PureConfig is a purely functional config parser, so it nicely wraps its result in an Either[ConfigFailures, Configuration], and we use ZIO.fromEither to create a ZIO from this result. Since we do not want to pass third-party errors around our application, we mapError the PureConfig error to our own ConfigurationError.

Note that, in contrary to the XModule, we do not need to provide an instance: the configuration is directly parsed from the application.conf file on the classpath.

Also, in the accessor we do not have to retrieve the full Environment from ZIO, we can access it and and provide a mapping to get directly to the Configuration we need, so convenient! Let’s provide our Main with the ConfigurationModule:

Listing 9. giving our Main access to the application config
import com.jdriven.cvdsteeg.zio.XModule.X
import zio._
import zio.console.Console

object Main extends App {
  def run(args: List[String]): ZIO[ZEnv, Nothing, Int] = program

  val logic: ZIO[Console with ConfigurationModule with XModule, Nothing, Int] = (for {
    _      <- console.putStrLn(s"I'm running!")
    x      <- XModule.factory.x
    _      <- console.putStrLn(s"I've got an $x!")
    config <- ConfigurationModule.factory.configuration
    _      <- console.putStrLn(s"This is the ${config.appName} application")
  } yield 0)
    .catchAll(e => console.putStrLn(s"Application run failed $e").as(1))

  private val program = logic
    .provideSome[Console] { c =>
      new Console with XModule.Live with ConfigurationModule.Live {
        override val console: Console.Service[Any] = c.console
        override val xInstance: X                  = X()
      }
    }
}

To do this, we again add the ConfigurationModule dependency to the custom dependencies we provide to our logic, and use the factory to access the Configuration.

To get you started creating your own custom environments, take a look at the examples in the source code that can be found on GitLab.

Also, I would like to thank Pierangelo Cecchetto for sparking my interest in this topic and his feedback on this post.

shadow-left