Skip to main content
Scala: Intro to ZIO
  1. Posts/

Scala: Intro to ZIO

·642 words·4 mins·
Roman
Author
Roman
Photographer with MSci in Computer Science and a Home Lab obsession
Table of Contents

Moving into Week 3, I’m stepping into more advanced Scala territory. After getting comfortable with SBT, currying, and collections in Week 2, I felt ready to tackle something that initially seemed intimidating: ZIO.

ZIO is Scala’s powerhouse library for functional effects and dependency management.

Good Intro into ZIO and ZLayers Structuring Services in Scala with ZIO and ZLayer - YouTube

Text form Organizing Services with ZIO and ZLayers | Rock the JVM

Getting Started with ZIO
#

ZIO is Scala’s answer to functional effect systems. Think of it as a way to describe computations without immediately executing them.

What is ZIO?
#

At its core, ZIO is about describing effects rather than executing them immediately. T

The ZIO Type: ZIO[-R, +E, +A]

  • R: Environment/Dependencies - what your computation needs to run
  • E: Error type - what can go wrong (any type, not just exceptions!)
  • A: Success value - what you get when things work

Conceptually: Think of it as R => Either[E, A] - given some environment R, you either get an error E or a success value A.

🛠️ Setting Up ZIO
#

The quickest way to experiment with ZIO is through scala-cli:

scala-cli repl --dep "dev.zio::zio:2.1.17"

Once inside the REPL:

import zio.ZIO

Basic ZIO Operations
#

val successfulComputation = ZIO.succeed("Yay") // ZIO[Any, Nothing, String]
val aFailure = ZIO.fail("Ooops") // IO[String, Nothing]

Example console with user input (side effect):

val hello = for {
  _ <- putStrLn("What's your name?")
  name <- getStrLn
  _ <- putStrLn(s"Hi, $name")
} yield ()

ZLayer Pattern
#

Core Concept
#

  • Services are created through effects
  • ZLayer wraps service creation in an effect
  • Type: ZLayer[RIn, E, ROut]
    • RIn: Dependencies needed
    • E: Creation error type
    • ROut: Service output (wrapped in Has[_])

Service Structure Pattern
#

object ServiceName {
  type ServiceNameEnv = Has[ServiceName.Service]
  
  trait Service {
    def method(): Task[Unit] // define method params for input, and Task type for output
  }

  // Service Implementation
  val live: ZLayer[Any, Nothing, ServiceNameEnv] =
    ZLayer.succeed(
      new Service {
        override def method(): Task[Unit] =
          Task {
            // Does some effect
          }
      })

  // Higher-level API
  def method(): ZIO[ServiceNameEnv, Throwable, ReturnType] =
    ZIO.accessM(_.get.method())
}

Example of how to use it in the app:

object ZLayerPlayground extends zio.App {
  override def run(args: List[String]): ZIO[zio.ZEnv, Nothing, ExitCode] =
    ServiceName
      .method() // the specification of the action
      .provideLayer(ServiceName.live) // plugging in a real layer/implementation to run on
      .exitCode // trigger the effect
}

ZLayer Composition
#

Horizontal Composition (++)
#

  • Combines independent layers
  • Sums dependencies: RIn1 with RIn2
  • Sums outputs: ROut1 with ROut2
  • Example: UserDb.live ++ UserEmailer.live
  • ZLayer[RIn1, E1, Out1] ++ ZLayer[RIn2, E2, Out2] => ZLayer[RIn1 with RIn2, super(E1, E2), Out1 with Out2]
val userBackendLayer: ZLayer[Any, Nothing, UserDbEnv with UserEmailerEnv] =
  UserDb.live ++ UserEmailer.live
UserDb.insert(User("Daniel", "[email protected]"))
  .provideLayer(userBackendLayer)
  .exitCode

Vertical Composition (>>>)
#

  • Function-like composition
  • Output of first becomes input of second
  • Example: backendLayer >>> subscriptionLayer

Dependency Injection
#

ZLayer.fromServices
#

  • Injects dependencies into service constructor
  • Example:
// type alias
type UserSubscriptionEnv = Has[UserSubscription.Service]

object UserSubscription {
  // service definition as a class
  class Service(notifier: UserEmailer.Service, userModel: UserDb.Service) {
    def subscribe(u: User): Task[User] = {
      for {
        _ <- userModel.insert(u)
        _ <- notifier.notify(u, s"Welcome, ${u.name}!.")
      } yield u
    }
  }
  
  // layer with service implementation via dependency injection
  val live: ZLayer[UserEmailerEnv with UserDbEnv, Nothing, UserSubscriptionEnv] =
    ZLayer.fromServices[UserEmailer.Service, UserDb.Service, UserSubscription.Service]((emailer, db) =>
      new Service(emailer, db)
    )
  
  // accessor
  def subscribe(u: User): ZIO[UserSubscriptionEnv, Throwable, User] = 
    ZIO.accessM(_.get.subscribe(u))
}
val userSubscriptionLayer: ZLayer[Any, Throwable, UserSubscriptionEnv] =
  userBackendLayer >>> UserSubscription.live

object ZLayersPlayground extends zio.App {
  override def run(args: List[String]): ZIO[zio.ZEnv, Nothing, ExitCode] = {
    val userRegistrationLayer = (UserDb.live ++ UserEmailer.live) >>> UserSubscription.live

    UserSubscription.subscribe(User("daniel", "[email protected]"))
      .provideLayer(userRegistrationLayer)
      .exitCode
  }
}

Key Methods
#

  • ZLayer.succeed(): Create layer from value
  • ZLayer.fromServices(): Create layer with dependency injection
  • ZIO.accessM(): Access service from environment
  • .provideLayer(): Provide layer implementation to ZIO effect

Benefits
#

  • Modularity: Independent, composable services
  • Testability: Easy to swap implementations
  • Type Safety: Compile-time dependency checking
  • Clarity: Explicit dependency relationships