Riccardo Cardin
35 min read • • Explanation • Intermediate
Share on:
We love functional programming for good reasons. Pure functions are predictable. The substitution model makes reasoning easy. Referential transparency means we can replace expressions with their values without changing program behavior. But here’s the tension that every functional programmer faces: the real world is messy. We need randomness, database queries, HTTP requests, and file I/O. How do we reconcile functional purity with the side effects that make our programs useful?
Let’s explore the different approaches to managing side effects in Scala, from the established monadic effect systems like Cats Effect and ZIO to the novel direct-style effect handlers enabled by Scala 3’s context functions.
The code in this article uses several libraries to illustrate different approaches. To follow along, ensure you have Scala 3 set up in your development environment. Moreover, let’s add the following dependencies to our build.sbt file:
libraryDependencies ++= Seq( "org.typelevel" %% "cats-effect" % "3.6.3", "dev.zio" %% "zio" % "2.1.24", "io.getkyo" %% "kyo-core" % "1.0-RC1")Let’s take an example. Imagine a drunk gambler at a casino who wants to flip a coin. Because they’re drunk, the flip might go wrong. They might drop the coin, flip it into their drink, or miss the catch entirely. In code, this might look like:
import scala.util.Random
def drunkFlip(): String = { val caught = Random.nextBoolean() val heads = if (caught) Random.nextBoolean() else throw new Exception("We dropped the coin!") if (heads) "Heads" else "Tails"}This code has problems that go beyond the gambler’s alcohol consumption. It uses randomness, so calling it twice might yield different results. It throws exceptions, which can break our program in unexpected ways. We can’t test it without actual randomness. We can’t compose it safely with other operations because it might explode at any moment.
To understand why this is so problematic, we need to talk about referential transparency. An expression is referentially transparent if we can replace it with its value without changing the program’s behavior. In other words, if val x = expr and we can freely substitute x with expr (and vice versa) everywhere in our code without altering what the program does, then expr is referentially transparent. This property is the foundation of the substitution model, which lets us reason about programs by replacing expressions with their values, step by step, just like simplifying an algebraic equation.
Let’s try to apply the substitution model to our drunk flip. Consider the following code:
val result1 = drunkFlip() // Might throw, might return "Heads" or "Tails"val result2 = result1 // Same value as result1At first glance, result1 and result2 always hold the same value. Now, if drunkFlip() were referentially transparent, we should be able to substitute result1 with its definition drunkFlip() in the second line without changing the program’s behavior. Let’s try:
val result1 = drunkFlip() // Say it returns "Heads"val result2 = drunkFlip() // A fresh call — might return "Tails" or throw!The program changed. Before the substitution, result2 was guaranteed to equal result1. After the substitution, result2 might be different because each call to drunkFlip() can produce a different result or even throw an exception. We replaced equals with equals and got a different program. The substitution model breaks down, and drunkFlip() is not referentially transparent.
Why does this matter so much? Because referential transparency enables two powerful reasoning techniques. The first is equational reasoning: the ability to understand code by substituting equals for equals, just like we do in mathematics. If val x = 2 + 3, we know x is 5, and we can replace x with 5 anywhere. With pure functions, we can do the same. With drunkFlip(), we can’t. Each “equal” expression might evaluate to something different, so our algebraic reasoning breaks down.
The second technique is local reasoning: the ability to understand a piece of code by looking only at that piece, without needing to know what the rest of the program is doing. When functions are pure, we can read a function’s signature and body and understand exactly what it does. With side effects, that’s no longer possible. Our drunkFlip() function depends on hidden global state (the random number generator) and produces hidden control flow (the exception). To understand what happens when we call it, we need to consider the entire program’s state. Side effects destroy locality.
Side effects have invaded our pure functional world. We can’t apply the substitution model. We lose equational and local reasoning. We say that the function is impure, and impurity spreads like a virus through our codebase.
Is it all doom and gloom? Not at all. Functional programming has a solution for managing side effects while preserving purity.
The solution is the Effect Pattern, a technique for tracking and controlling side effects. The pattern has two core principles. First, type tracking means effects are encoded in types, so we can see what a function does just by looking at its signature. Second, deferred execution means building the effect description separately from running it. But how do we implement this pattern? The Scala ecosystem offers several approaches, each with different trade-offs. Some use the traditional monadic style with flatMap and map. Others leverage Scala 3’s context functions to make effects look almost like imperative code. We’ll explore both paths and understand when each makes sense.
The Effect Pattern rests on two fundamental principles that transform how we handle side effects. First, type tracking means we encode effects in types. Instead of def getUser(): User, we write def getUser(name: String): IO[User] or def getUser(name: String): ZIO[Database, Exception, User]. The type signature tells us this function performs effects. We can see what kind of effects it uses without reading the implementation. If we use the IO type, we’re saying “this function might do side effects when executed”. If we use ZIO[Database, Exception, User], we’re saying “this function needs the capability to access to a Database, might fail with an Exception, and produces a User on success”. This explicitness is powerful. It makes reasoning about code easier. (Don’t worry, you don’t need to know what Cats Effect IO or ZIO ZIO are to understand the concepts we’ll explore).
The type system becomes documentation.
Second, deferred execution means we separate description from execution. Think of it like a recipe versus cooking. A recipe describes steps: mix flour and eggs, bake at 350 degrees, and frost when cool. But the recipe doesn’t bake anything. It’s just information on paper. Cooking is when you actually perform those steps and produce food. Similarly, an effect is a description: read this file, parse this JSON, query this database. But nothing happens when you create the effect. Execution happens later, when you explicitly run it at the boundaries of your system.
So, if we call the getUser function we defined earlier, we get back an effect description. Nothing happens yet:
val user: IO[User] = getUser("rcardin") // Just a description, no executionWhy go through this effort? The benefits are concrete and immediate. Consider referential transparency first. As we said, without the Effect Pattern, our drunk flip breaks substitution. With the Effect Pattern, we get referential transparency back:
val result1 = IO(drunkFlip()) // A description, not an executionval result2 = result1// result1 and result2 are identical descriptions// The substitution model works again!Testability improves dramatically. We can swap in mock implementations instead of running real effects. Need to test code that queries a database? Provide a test handler that returns predictable values. No actual database required. No HTTP calls to external services. No waiting for file I/O. Tests run fast and deterministically.
Composition becomes natural and safe. We chain effects together using combinators. Build complex workflows from simple pieces. Retry on failure, run operations in parallel, and add timeouts. All without changing the core logic:
// Effect composition with retry policyval resilientUser: IO[User] = getUser("rcardin") .timeout(5.seconds) .retry(Schedule.exponential(1.second).jittered && Schedule.recurs(3)) .handleErrorWith { error => IO.println(s"Failed to fetch user: $error") *> IO.raiseError(error) }It’s not important which specific library we use. The above code wrapped the getUser effectful operation with retries, timeouts, and error handling, and we still have an IO[User] back.
Now that we understand what the Effect Pattern gives us, how do we actually implement it? The implementation of the Effect Pattern is called an Effect System. As we said, we need two key features: type tracking and deferred execution. Then, we need structures to manage and execute effects.
The Scala ecosystem has explored several approaches, each with different trade-offs. Let’s start with the most established path: monadic effect systems.
The most established approach to effect systems belongs to functional programming and it uses monads (if you need a refresher on monads, check out the article An Introduction to Monads in Scala). If we’ve worked with Scala’s Option, Try, or Future, we’ve already used monads. The pattern is consistent across all of them: wrap computations in a type, chain them with flatMap and map, and run them when ready. Monadic effect systems apply this familiar pattern to side effects. Let’s explore three major implementations that each solve the Effect Pattern in slightly different ways.
Cats Effect’s IO monad is what we call an ‘über effect’. One type tracks and handles all effects. Every operation that might have side effects gets wrapped in IO. Reading files? IO. Making HTTP requests? IO. Generating random numbers? IO. The benefit is uniformity. We don’t need to learn different types for different effects. The cost is that we miss some precision. An IO[A] tells us “this might do effects and produces an A or might fail with an exception”, but it doesn’t tell us what kind of effects or how it might fail (we have full courses on Cats Effect: the Cats Effect course and the Typelevel Rite of Passage. Check them out!).
We can write our drunk flip function using Cats Effect:
import cats.effect.IOimport cats.effect.std.Random
def drunkFlip: IO[String] = for { random <- Random.scalaUtilRandom[IO] caught <- random.nextBoolean heads <- if (caught) random.nextBoolean else IO.raiseError(new Exception("We dropped the coin!")) } yield if (heads) "Heads" else "Tails"Here’s what’s happening: each side effect is wrapped in IO. The for-comprehension, which is syntactic sugar for flatMap chains, sequences operations. First, get a random generator. Then, check if we caught the coin. Then, either fail with an error or flip again. Nothing executes until we call unsafeRunSync() or provide an IOApp that runs the effect for us:
object Main extends IOApp.Simple { def run: IO[Unit] = drunkFlip.flatMap(result => IO.println(result))}
// Or
val result: String = drunkFlip.unsafeRunSync()val resultF: Future[String] = drunkFlip.unsafeToFuture()Notice we’ve achieved the Effect Pattern. Type tracking works: the signature IO[String] tells us this performs effects and produces a String. Deferred execution works: building the effect doesn’t run anything. Execution happens when we explicitly trigger it.
If we want to have the exact list of the effects used, we can use a different approach, using tagless final style with type classes to represent capabilities (don’t know tagless final? Check out the article Tagless Final in Scala Quickly Explained):
def drunkFlipF[F[_]: Monad](using R: Raise[F, String], A: Random[F]): F[String] = for { caught <- A.nextBoolean heads <- if (caught) A.nextBoolean else R.raise("We dropped the coin") } yield if (heads) "Heads" else "Tails"The Raise and Random type classes represent the required effects. They are included in the Cats MTL library. This approach gives us more precision about the effects used, but it adds even more complexity with type classes.
Notice the cognitive overhead. We need to understand flatMap and for-comprehensions. We need to remember which operations need IO wrappers. For developers new to functional programming, this is not intuitive. Each operation requires thinking about monadic context. The learning curve is real.
ZIO goes further than Cats Effect. Instead of just IO[A], we get ZIO[R, E, A] where R represents requirements or dependencies, E represents the error type, and A represents the success type. This is more precise than a simple IO wrapper. The type signature tells us everything about the computation: what dependencies does this function need? How can it fail? What does it produce on success? (Again, we have plenty of courses on ZIO too. Check out the ZIO course and the ZIO Rite of Passage)
Here’s drunk flip in ZIO:
import zio._
def drunkFlip: ZIO[Random, String, String] = for { caught <- Random.nextBoolean heads <- if (caught) Random.nextBoolean else ZIO.fail("We dropped the coin") } yield if (heads) "Heads" else "Tails"The signature ZIO[Random, String, String] is remarkably expressive. This operation needs to generate random numbers (the Random dependency), might fail with a String, and produces a String on success. We get more type-level information than Cats Effect’s IO. The trade-off is that the type is more complex to understand initially.
In ZIO 2, the Random service is actually provided by default in the ZIO runtime, so we don’t strictly need to declare it in the environment. We could write ZIO[Any, String, String] and call Random.nextBoolean just fine. However, we explicitly include Random in the environment here to make the dependency visible in the type signature, so we can compare it more directly with the other approaches.
Dependencies compose using Scala 3 intersection types. If we need both randomness and access to the console for logging, we can express that:
val drunkFlipOnConsole: ZIO[Random & Console, String, Unit] = drunkFlip.flatMap { result => Console.printLine(result) }Running the effect requires providing the necessary environment inside a ZIO app:
object Main extends ZIOAppDefault { override def run = drunkFlipOnConsole.provide(Random.live, Console.live)}But we’re still in monad-land. The ZIO type is a monad defined on the type A. It’s still an über-monad in the sense that we still need to execute everything in a single step.
The for-comprehension is still there. The cognitive load of flatMap chains remains. ZIO provides a richer algebra for working with effects than Cats Effect, avoiding some of the monad transformer problems, but the fundamental monadic style persists.
The power is undeniable, but so is the learning curve for newcomers to functional programming.
Kyo takes a different approach that introduces algebraic effects to Scala. Instead of one über effect type, you list effects separately and can handle them independently. This is more modular than the über effect approach. Want randomness and error handling? Compose Random and Abort. Need different effects elsewhere? Use a different composition.
The drunk flip function in Kyo becomes:
import kyo.*
def drunkFlip: String < (Abort[String] & Sync) = for { caught <- Random.nextBoolean heads <- if (caught) Random.nextBoolean else Abort.fail("We dropped the coin") } yield if (heads) "Heads" else "Tails"The type String < (Abort[String] & Sync) means “a String computation with Abort and Sync effects”. The Random operations in Kyo return values with the Sync effect pending. The Sync effect defers any computations that can perform side effects.
The nice part is that we can handle effects separately. It’s one of the appealing properties of algebraic effect systems. Want to run just the error handling? Use Abort.run:
val partialResult: Result[String, String] < Sync = Abort.run { drunkFlip }As we can see, the result is a Result[String, String] < Sync, meaning we have handled the Abort effect, but the Sync effect is still pending.
Why is Kyo still a monadic approach? Because under the hood, Kyo uses monadic composition. The < (pending) operator is a type alias for a monadic context that combines effects:
opaque type <[+A, -S]The type A is the result type, and S is the stack of effects. The composition of effects is still monadic, In this sense, the < is still an uber-type. The difference is that effects are modular and can be handled independently. This is more modular than Cats Effect IO and more compositional than ZIO’s three-parameter type.
But look at the code carefully. We’re still using for-comprehensions. Under the hood, Kyo uses monadic composition. The algebraic approach is elegant and solves real composition problems, but the monadic cognitive load persists. We’re still thinking in terms of flatMap chains, even if Kyo makes those chains more flexible and modular.
All three approaches are an elegant implementation of the Effect Pattern. They track effects in types, giving us compile-time safety. They defer execution until we’re ready, separating description from action. They provide rich algebras for composing effects. Cats Effect and ZIO are used in production systems worldwide and have proven their worth. But they share a challenge that we can’t ignore: monadic composition has a steep learning curve.
Understanding flatMap, knowing when to use map versus flatMap, reasoning about nested monadic contexts, and understanding what for-comprehensions desugar to—these are barriers for developers new to functional programming. There are barriers for teams trying to adopt functional effect management. There are barriers that slow down development and make code reviews harder when not everyone has the same monadic intuition.
This raises a question. Can we keep some of the benefits of the Effect Pattern, type tracking, and deferred execution, while reducing the cognitive load? What if our code could look almost like imperative code but still be safe and composable? What if we didn’t need flatMap at all? Enter direct-style effect handlers.
What if we could write code that looks like this:
def drunkFlip(using Random, Raise[String]): String = { val caught = Random.nextBoolean val heads = if (caught) Random.nextBoolean else Raise.raise("We dropped the coin") if (heads) "Heads" else "Tails" }We abandoned for a moment the functional programming world. No IO. No for-comprehension. No flatMap. Just normal-looking sequential code that reads like the imperative version we started with. This is what direct-style effect handlers enable using Scala 3’s context functions. So, without further ado, let’s see how this works.
The magic behind direct-style effects is context parameters and functions. The latter is a Scala 3 feature that fundamentally changes how we think about deferred computation. To understand how they enable the implementation of the Effect Pattern, we first need to understand what context functions actually are and how they differ from regular functions.
In Scala 3, we have two ways to pass parameters implicitly. The first is context parameters in functions and methods, which we declare with the using keyword:
def greet(name: String)(using logger: Logger): Unit = logger.log(s"Hello, $name")When we call greet("Alice"), the compiler looks for a Logger instance in scope and passes it automatically. This is familiar from Scala 2’s implicits, just with cleaner syntax (see Scala 3: Givens and Implicits Quickly Explained for further details).
The second mechanism is context functions, and this is where things get interesting. A context function is a function type where some of its parameters are passed implicitly, as if they were context parameters. The syntax uses ?=> instead of =>:
def greet(name: String): (logger: Logger) ?=> Unit = logger.log(s"Hello, $name")This reads as “given a Logger implicitly, and a String, produce an Unit”. The ?=> arrow indicates that the Logger will be provided through Scala’s context mechanism, not passed explicitly. We can even summon the instance of the Logger inside the function body using summon[Logger] instead of having a named parameter:
def greet(name: String): Logger ?=> Unit = summon[Logger].log(s"Hello, $name")The value greet is a function that requires a Logger to be in implicit scope before it can execute. Nothing happens when we define it. The greeting isn’t logged. The function just sits there, waiting. To actually run it, we need to provide the context:
given Logger = new Loggergreet("Alice") // Now prints "Hello, Alice"
// Or explicitly:greet("Bob")(using new Logger) // Prints "Hello, Bob"As you can see, it’s the same idea as context parameters, but now the entire function is a context function. This is crucial to implement a machinery that enables deferred execution. The deferral comes from requiring a context that hasn’t been provided yet. The function body describes what to do, but it cannot execute until we supply the necessary context inputs. The Scala compiler automatically threads context parameters through nested calls. When a function needs an effect through a using parameter, and we call that function inside another context that also requires the same effect, the compiler passes it along invisibly:
def innerOperation(using logger: Logger): Unit = logger.log("Inner operation")
def outerOperation(using logger: Logger): Unit = { logger.log("Starting outer") innerOperation // Compiler passes Logger automatically! logger.log("Ending outer")}When we write innerOperation inside outerOperation, the compiler sees that innerOperation needs a Logger context parameter. It also sees that outerOperation has a Logger in scope. So it passes it along automatically. We don’t explicitly write innerOperation(using logger). The context parameter flows through our code invisibly, threaded by the compiler.
This automatic threading is the foundation of direct-style effects. Functional programming uses to wrap effects in monads. Instead, we write functions that require an instance of a capability to run the effect. That capability is passed implicitly as a context parameter. Think of it like this: monadic style says “here’s a wrapped effect, run it by calling methods on it”. Direct-style says, “Here’s code that looks normal, but it can’t run without some context.” The context is the capability, and the compiler ensures you can’t execute effectful code without it.
So, in direct-style, we identify the effect as C ?=> A, where C is the capability we need to run the effect, and A is the result type.
In the previous section, we saw how context functions enable deferred execution. Context parameters in context functions allow us to track the effects a function needs to run. It seems we can build an effect system using these concepts.
So, let’s build our own minimal effect system to understand how this works. First, we need to define the types that represent capabilities. A capability in this scenario is just a trait describing what effectful operations we can perform:
trait Random { def nextBoolean: Boolean}
trait Raise[E] { def raise(error: E): Nothing}These are just interfaces. Given to a context function, they represent “the permission to perform the effect”. Functions that need these capabilities declare them with using parameters. Without a concrete instance of the capability type in scope, the function can’t compile. The type system enforces that capabilities can only be used when we have permission.
Now we can write our drunk flip using direct-style effects:
def drunkFlip(using Random, Raise[String]): String = { val caught = Random.nextBoolean val heads = if (caught) Random.nextBoolean else Raise.raise("We dropped the coin") if (heads) "Heads" else "Tails" }Look at that code. It’s imperative code with effects tracking enabled: check a condition, abort if needed, otherwise flip the coin. But notice the using parameters in the signature. This function requires instances of the Random and Raise capabilities to run. Without them in scope, calling this function is a compile error. This is how we achieve deferred execution. The function body describes what to do, but it can’t do anything until someone provides the capabilities.
To run the effectful program, we provide implementations called handlers. Handlers are where the rubber meets the road, where description becomes execution:
object Random { def run[A](program: Random ?=> A): A = { val randomCapability = new Random { def nextBoolean: Boolean = scala.util.Random.nextBoolean() } program(using randomCapability) }}
object Raise { def either[E, A](program: Raise[E] ?=> A): Either[E, A] = { import scala.util.boundary, boundary.break boundary { val raiseEffect = new Raise[E] { def raise(error: E): Nothing = break(Left(error)) } Right(program(using raiseEffect)) } }}As we can see, handlers are where the context function syntax comes into play. The effectful program a handler receives is a context function that requires the capability instance. The handler creates a concrete implementation of the capability and provides it to the program using program(using capabilityInstance).
These handlers “run” capabilities by providing actual implementations. The Random.run handler provides a real random capability instance that uses Scala’s Random under the hood. The Raise.either handler provides an abort capability instance that uses Scala 3’s boundary/break control flow to implement early return on failure. When we call the program function with an instance of the capability in scope, the capability executes. This is where deferred execution ends and real execution begins:
val result: Either[String, String] = Random.run { Raise.either { drunkFlip }}The Raise.either handler uses Scala 3’s boundary and break for control flow. Think of boundary as defining a labeled scope and break as a way to exit that scope early with a value. When raise is called, break(Left(error)) immediately exits the boundary block and returns Left(error). This is how we convert an abortion into an Either without using exceptions. If the program succeeds, we wrap the result in Right.
The order of handlers matters. That’s not the case in our simple example, but in more complex scenarios, the order in which we handle capabilities can change behavior.
As in Kyo, we can handle effects separately:
val partialResult: Raise[String] ?=> String = Random.run { drunkFlip}The partialResult still needs a Raise[String] capability to run, but the Random effect has been handled. It’s something like algebraic effects.
Notice that each handler can interpret effects differently. The Random handler uses real randomness, but we could write a test handler that returns fixed values:
def test(fixed: Boolean)(program: Random ?=> Boolean) = { program(using new Random() { override def nextBoolean: Boolean = fixed })}The Raise handler converts failures to Either, but we could write a handler that logs errors, or retries, or does something else entirely. This flexibility is the power of the effect pattern. We separate the description of effects from their interpretation.
Here’s where direct style gets really elegant. Composition is automatic and feels like normal function calls. If function A calls function B, and both need the same capability, it just works:
def flipTwice(using Random): Int = { val first = Random.nextBoolean val second = Random.nextBoolean (if (first) 1 else 0) + (if (second) 1 else 0)}
def flipMany(using Random): Seq[Boolean] = { Seq.fill(10)(Random.nextBoolean)}
def analyze(using Random): String = { val count = flipTwice val flips = flipMany s"Got $count true values in first two flips, and ${flips.count(identity)} in next ten"}The Scala compiler threads the using parameters through automatically. When analyze calls flipTwice, the compiler sees that flipTwice needs the Random capability. It also sees that analyze has a Random capability in scope. So it passes it along. No explicit flatMap needed. No for-comprehension to chain things. Just normal function calls that compose naturally.
This is the magic of context functions. Composition looks like imperative code, but the compiler is doing sophisticated work behind the scenes. It’s tracking capabilities, ensuring type safety, and threading context through your program. The complexity is there, but it’s hidden from the developer. We write simple code, and the compiler handles the bookkeeping.
Now that we’ve seen how direct-style effects work, let’s be honest about what kind of deferral we’re getting. As we said, the Effect Pattern needs deferred execution. But there are different kinds of deferral.
In monadic systems, IO[A] is a data structure that describes a program. A runtime interprets it. We can store the program, retry it, race it, compose it, and run it multiple times. This is the strongest form of deferred execution: programs as data.
In direct-style, the effectful program is a context function (C ?=> A). It won’t execute until a handler provides the capability. We can store it, pass it around, and choose when to run it. This is real deferral, but it’s not the same as programs-as-data.
The key difference: once we provide the context and the function body runs, effects happen eagerly. There’s no data structure that a runtime can inspect, optimize, or re-execute. Let’s see an example:
// Monadic: program is a reusable data structure (pseudocode)val program: IO[Int] = IO(Random.nextInt())program.retryN(3) // re-invokes the computation three timesprogram.race(program) // two independent executions
// Direct-style: program is a function, not dataval program: Random ?=> Int = Random.nextInt// Can't retry or race without wrapping in explicit lambdasIn the monadic version, program is a value describing what to do. The runtime can run that description as many times as it wants. In the direct-style version, program is a function waiting for its context. Once we provide the context, the body runs once and gives us a result. If we want to retry or race, we need to wrap the computation in lambdas or rebuild it. For example, we can do retries in direct-style, but we have to be explicit:
// Direct-style: retries require an explicit loopdef retry[A](n: Int)(program: => A): A = try program catch case e: Exception if n > 0 => retry(n - 1)(program)
// Usageretry(3) { Random.run { Random.nextInt }}Notice that the caller must pass the computation as a by-name parameter (=> A) so that each retry runs the body again. In monadic style, this comes for free because IO[A] is already a description we can re-run. In direct-style, we have to build the deferral ourselves.
Future?A fair question at this point: if direct-style effects lose referential transparency and don’t give us programs-as-data, aren’t we just reinventing Future with an implicit ExecutionContext? Both approaches use an implicit parameter, so the similarity is there. But the comparison doesn’t hold.
The first difference is eagerness. Future is eager: the moment we create one, its body starts running immediately:
import scala.concurrent.Futureimport scala.concurrent.ExecutionContext.Implicits.global
val f: Future[Int] = Future(expensiveComputation()) // Already running!val g: Future[Int] = f // g shares f's result, not the computationA context function, instead, does nothing until a handler provides the capability:
val program: Random ?=> Int = Random.nextInt // Nothing happens yet// Only executes when we provide the handler:Random.run { program } // Now it runsThe second difference is accidental concurrency. With Future, two independent operations might run in parallel depending on where we create them. This leads to subtle bugs that are hard to reproduce. Direct-style sequential code runs sequentially, period. If we want parallelism, we have to ask for it.
The third difference is memoization. Future caches its result: once it completes, calling .map on the same Future twice gives the same value. Context functions re-execute every time we provide the context. Each call is a fresh computation.
In short, Future is eager, accidentally concurrent, and memoized. Direct-style is deferred, sequential, and re-executable. They only share the surface syntax of implicit parameters.
In other words, direct-style sits between Future (no deferral at all) and IO (full programs-as-data). It fixes Future’s worst problems while giving up IO’s strongest guarantees. As we’ll see in the trade-offs section, this has real consequences for what we can and can’t do.
Let’s compare approaches directly. Here’s the Cats Effect version:
def drunkFlip: IO[String] = for { random <- Random.scalaUtilRandom[IO] caught <- random.nextBoolean heads <- if (caught) random.nextBoolean else IO.raiseError(new Exception("We dropped the coin!")) } yield if (heads) "Heads" else "Tails"And here’s the direct-style version:
def drunkFlip(using Random, Raise[String]): String = { val caught = Random.nextBoolean val heads = if (caught) Random.nextBoolean else Raise.raise("We dropped the coin") if (heads) "Heads" else "Tails" }The difference is evident. The direct-style version looks almost identical to the original imperative code we started with. No flatMap or for-comprehension wrapping our logic. No explicit IO wrapping every operation. Yet it’s still type-safe because the effects are in the signature. And it’s still deferred because of the machinery we built. We’ve achieved the Effect Pattern off the functional programming world.
As we said earlier, the goal was to make effects look like imperative code. But let’s be honest. Direct style isn’t perfect. It makes trade-offs that might matter for some applications. The question is whether those trade-offs are worth it for your specific situation.
Let’s examine what we lose and what we gain.
What we lose is strict referential transparency at the value level. Consider this code:
def drunkFlip(using Random, Raise[String]): String = { val genRand = Random.nextBoolean val caught = genRand val heads = if (caught) genRand else Raise.raise("We dropped the coin") if (heads) "Heads" else "Tails"}When the handler for Random is provided, the genRand value is evaluated eagerly. Each time we reference genRand, we get the same value. This breaks referential transparency at the value level. We can’t replace genRand with Random.nextBoolean without changing behavior, because genRand is only evaluated once.
To regain something similar to referential transparency, we need to define genRand as a function:
def drunkFlip(using Random, Raise[String]): String = { def genRand = Random.nextBoolean val caught = genRand val heads = if (caught) genRand else Raise.raise("We dropped the coin") if (heads) "Heads" else "Tails"}As you can see, genRand is now a def rather than a val, so each time we call it, we get a fresh random value. It’s not referential transparency, since we’re not representing programs through values, but we’re just calling functions. Moreover, we have to remember to use def for computations that need re-evaluation.
This has consequences beyond a single function. As we discussed earlier, referential transparency enables equational reasoning and local reasoning. Without it, we lose the ability to refactor code by freely substituting equals for equals. In monadic style, if we see val x = someEffect and val y = someEffect, we know they are two independent descriptions of the same computation, and we can extract them into a shared value without changing behavior. In direct style, the same refactoring might silently change our program because val binds eagerly. Extracting a common subexpression into a val or inlining a val back into its usage sites are everyday refactoring moves, and in direct style they can introduce subtle bugs. We need to be careful about whether we’re dealing with a value or a computation, and that distinction is no longer visible in the code itself.
This connects to a deeper point. As we saw in the “What Kind of Deferral Is This?” section, monadic IO gives us programs as data: we can retry, race, and compose program descriptions freely. In direct-style, the computation is a function, not data. In terms of continuations, monadic IO gives us multi-shot continuations (the runtime can run the same program description multiple times), while direct-style gives us one-shot continuations (each computation runs exactly once). As we’ll see shortly, this tells us which effects we can express and which we can’t.
However, do we really need referential transparency? Referential transparency is a core concept in programming models where we can express programs as values. In this setup, we need to be able to substitute values freely without changing program behavior. This is crucial for reasoning about code, composing it, and ensuring correctness. In particular, it’s crucial for managing the effects’ control flow safely.
If we return to a setup where we use an imperative style, we lose the ability to treat our programs as values and thus the full power to manipulate their control flow. In fact, the direct-style approach we presented can’t express all possible effects (e.g., the Choice effect). This is because we can’t express all types of continuations with context functions. (Yes, it’s all a matter of continuations). Context functions can express a subset of continuations called one-shot continuations.
To make this concrete, consider a realistic example: we’re building a configuration validator that needs to find all valid combinations of database and cache settings. With Kyo’s Choice effect, we can express this naturally in monadic style:
def compatible(db: String, cache: String): Boolean = !(db == "sqlite" && cache == "redis-cluster")
def validConfigs: (String, String) < Choice = for { db <- Choice.eval("postgres", "mysql", "sqlite") cache <- Choice.eval("redis", "redis-cluster", "memcached") _ <- Choice.dropIf(!compatible(db, cache)) } yield (db, cache)// Produces ALL valid combinations at onceval allValid: Seq[(String, String)] < Any = Choice.run(validConfigs)Each call to Choice.eval invokes the continuation, everything after it in the for-comprehension, once per element in the sequence. For three databases and three caches, the continuation after the first Choice.eval runs three times, each spawning three more runs. The filter then prunes incompatible pairs. This requires multi-shot continuations: the ability to run the same continuation multiple times with different inputs. In monadic style, flatMap receives the continuation as a function A => F[B], and the Choice monad can call that function as many times as it needs.
Direct-style context functions can’t express this. When a handler provides an effect instance and calls program(using effectInstance), the program body runs exactly once. There’s no mechanism for the handler to “replay” or “restart” the computation from a certain point with a different value. This is a fundamental limitation of one-shot continuations: each continuation is invoked at most once. So while we could write a Choice trait with a eval method, there’s no way to implement a handler that explores all branches.
In many practical applications, the effects that we can express with direct-style handlers are sufficient. Most applications don’t need the full power of continuations or advanced control flow. They just need to manage side effects such as randomness, I/O, and error handling in a safe, composable way. In these common cases, direct-style handlers provide a simpler, more approachable way to implement the Effect Pattern.
The other limitation is that context functions are Scala 3 only. Teams still on Scala 2 can’t use this approach. There’s no way to backport context functions to Scala 2. Also, understanding using parameters and how context functions work requires learning Scala 3 features. That said, understanding context functions is arguably easier than understanding monads and flatMap. The barrier to entry is lower, but it’s still a barrier for teams without Scala 3 experience.
What we gain is readability. Code looks imperative and familiar. A developer who has never seen functional programming can read direct-style effects and understand what’s happening. They might not immediately understand how using parameters work, but the sequential flow is obvious. There’s no mental overhead of tracking monadic contexts or figuring out what a for-comprehension desugars to.
We also gain freedom from accidental concurrency. This was probably the biggest practical problem with Future-based code: two Future values created independently could run in parallel without us intending it. In direct-style, sequential code runs sequentially. If we want parallelism, we have to use constructs like structured concurrency. What we write is what we get, no hidden concurrency surprises.
We also gain approachability. The cognitive load is dramatically lower compared to monadic approaches. No need to understand flatMap chains. No confusion about when to use map versus flatMap. No nested for-comprehensions to mentally parse. Just write sequential code that looks like what you mean, and let the compiler thread capabilities for you. This significantly lowers the barrier to teams adopting functional effect management.
To summarize the trade-offs across all three approaches, here’s a quick comparison:
| Property | Future | Direct-style | IO (ZIO/CE) |
|---|---|---|---|
| Deferred until run | No (eager) | Yes (until handler) | Yes (until runtime) |
| Referential transparency | No | No | Yes |
| Accidental concurrency | Yes | No | No |
| Programs as data | No | No | Yes |
| Type-tracked effects | No | Yes | Yes |
As we can see, direct-style sits in the middle. It avoids Future’s pitfalls while being more approachable than full monadic IO, at the cost of some guarantees that monadic systems give us.
Direct style isn’t the best for every problem. Some use cases benefit from explicit monadic structure, especially when you need the additional operations that monads provide or when you’re already deeply invested in a monadic ecosystem. But it’s a valid alternative in many cases. The choice depends on team experience, project constraints, and personal preference. Both approaches implement the Effect Pattern correctly.
We’ve explored two approaches to the Effect Pattern in Scala. Functional programming monadic systems like Cats Effect, ZIO, and Kyo use explicit effect types and flatMap composition. They’re powerful, battle-tested, and widely used in production systems worldwide. The learning curve is steep, but the payoff is robust effect management with rich combinators for composition, error handling, and concurrency. Direct-style systems use context parameters and functions to achieve the same goals with different syntax. They’re more readable and approachable, but they trade some referential transparency at the value level, and the set of effects they can express is limited. Both can implement type tracking and deferred execution. The difference is in syntax and cognitive load. In other words, both let us manage side effects safely. They just express that safety differently.
When should we use monadic approaches? When we’re working on Scala 2 projects that can’t upgrade to Scala 3. When our team is already comfortable with functional patterns and monadic reasoning. When we need maximum type safety and are willing to pay the learning curve cost. When we need the rich combinators and ecosystem that mature effect libraries provide. These are all good reasons to choose the monadic path.
When should we use direct style? On Scala 3 projects where context functions are available. When our team is new to functional programming, the monadic learning curve would slow down development. When we prioritize code readability and want effects to look like imperative code. When we’re building a new system, we can choose our abstractions freely. When we believe that 80% of our use cases don’t need the full power of monadic effects.
The Effect Pattern is the real insight here. Whether we express it with monads or context parameters and functions, we’re separating description from execution. That separation is what makes side effects manageable, testable, and composable. The implementation details are secondary to understanding that core principle. Once we understand the Effect Pattern, we can choose the implementation that fits our context. Both paths lead to safer, more maintainable code. The journey matters less than the destination.
We’ve built our own minimal effect system to understand the principles. But we don’t have to roll our own in production. The Scala ecosystem has direct-style libraries that provide implementations. Two notable ones are: Ox and YAES.
Share on:
This site uses cookies. Check our cookie policy (TLDR: no personal information is stored). For more information see our cookie policy.