This chapter turns the packaging idea of chapter 7 into a full theory of effects. It is based on the two-part seminar by Nicolas Farabegoli (DISI UniBo, material adapted from Gianluca Aguzzi and Giacomo Cavalieri), which is the bridge the previous chapter promised: the monadic MVC of chapter 7 packaged one effect (state, I/O) at a time; the seminar asks what happens when a program needs many effects, and walks the three answers the functional-programming community has produced — monad transformers, tagless final, and direct style with capabilities. The companion code lives at https://github.com/nicolasfara/asmd-effect-systems-overview-code.
The seminar opens with the textbook definition:
"A side effect is any change in program state or observable behavior beyond the function's return value."
Pure functions only depend on their input arguments and return a value without modifying any state or performing I/O operations. Impure functions may modify state, perform I/O operations, or have other observable behaviors beyond returning a value. The deck's warning is deliberately blunt:
"Side effects are lies. Your function promise to do one thing, but it also does other hidden things."
Why should we care? The deck gives four reasons, each one a different kind of damage:
The response is not to ban effects but to track them. Type systems make important program properties explicit instead of implicit; effect systems extend types so they describe not only values, but also the effects a computation may perform. The definition given in the deck is deliberately general:
"An effect system is kind of a type system that tracks the effects of computations, allowing developers to reason about and control them more effectively."
Effect systems are already in the wild, at very different levels of ambition: Java's checked exceptions (failure is part of the signature), monads in Haskell (effects are values), and Koka's algebraic effects (effect handlers as first-class constructs). Two examples from the deck show the problem being attacked. Throwing exceptions is a control-flow side effect: the function may fail unexpectedly, the error is not part of the return type so callers may forget to handle it, and the effects are "hidden" in the implementation, making reasoning and testing harder.
def divide(a: Int, b: Int): Int =
if (b == 0) throw new Exception("Divide by zero")
else a / b
Non-determinism is a side effect too: randomness breaks purity because different outputs are produced each time, the effect is hidden in the implementation rather than reflected in the function's type, and callers may be surprised by the non-deterministic behaviour.
import scala.util.Random
def getRandom(): Int = Random.nextInt(100)
The deck's diagnosis is that ad-hoc mechanisms do not scale: checked exceptions only talk about failures; randomness, state, logging, and I/O each need different mechanisms; and once a program combines many effects, reasoning becomes fragmented. What we really want, it says, is three things at once:
M[A] and combines such values through the monad interface of chapter 7.Instead of performing effects directly, represent an effectful computation as a value such as M[A], and use a common interface to combine these values. The seminar states it without ceremony: "That interface is the idea of a monad." Everything in this chapter — transformers, tagless final, capabilities — is a refinement of that single sentence, and the monad laws of chapter 7, section 2 are what keep every refinement predictable.
The seminar re-derives the monad idea in its own words: an effect concept represents the "additional context" that computations may have beyond just producing a value — state changes, I/O, exceptions — and "monads capture effects by structuring computations in a way that allows us to sequence operations while keeping track of the effects they produce." The definitions are the ones from chapter 7 with different names (pure instead of unit), in both Haskell and Scala:
-- Monad definition in Haskell
class Monad m where
return :: a -> m a
(>>=) :: m a -> (a -> m b) -> m b
// Monad definition in Scala
trait Monad[M[_]]:
def pure[A](value: A): M[A]
def flatMap[A, B](ma: M[A])(f: A => M[B]): M[B]
The three laws are restated in this vocabulary, and the deck adds the reason they matter: "These laws make monadic composition predictable. Without them, refactoring flatMap chains or using for-comprehensions could change behavior." This is exactly the property checked by the MONAD-VERIFIER task of chapter 7.
// Left identity: injecting a value with pure and then continuing with f
// should behave exactly like calling f directly
pure(a).flatMap(f) == f(a)
// Right identity: sequencing a computation and then doing "nothing more"
// with pure should not change the computation
ma.flatMap(pure) == ma
// Associativity: grouping effectful steps in different ways must preserve
// the meaning of the program
ma.flatMap(f).flatMap(g) == ma.flatMap(a => f(a).flatMap(g))
The seminar's catalogue of "famous monads" is the monad zoo of chapter 7, section 3 read as an effects list: Option[A] captures computations that may fail or return nothing; Either[E, A] captures computations that may fail with an error (typed failures — the new member); State[S, A] captures computations that manipulate state; IO[A] captures computations that perform input/output operations. The IO instance is a one-liner:
final case class IO[A](unsafeRun: () => A)
given Monad[IO] with
def pure[A](value: A): IO[A] = IO(() => value)
def flatMap[A, B](io: IO[A])(f: A => IO[B]): IO[B] =
IO(() => f(io.unsafeRun()).unsafeRun())
The type wraps a computation that produces a value of type A when executed; unsafeRun is the function that, when called, performs the actual I/O operation and returns the result. The deck adds a blunt production warning: "Do not use it in production! It lacks features like error handling, resource management, and concurrency support." — this minimal monad is a teaching device, and the rest of the seminar (and of this chapter) is about the machinery real effect systems need.
With the instance in place, console interaction is written as a for comprehension. Note the echo program: putLine, getLine, putLine — the effect of each step is tracked by the type, and the whole interaction is one value.
object IO:
def putLine(s: String): IO[Unit] = IO(() => println(s))
def getLine: IO[String] = IO(() => scala.io.StdIn.readLine())
def echo: IO[Unit] = for
_ <- IO.putLine("Enter something:")
input <- IO.getLine
_ <- IO.putLine(s"You entered: $input")
yield ()
Trying to print the program — rather than run it — shows what it is:
println(echo)
// IO(io.github.nicolasfara.intro.IO$given_Monad_IO$$$Lambda/0x00007f6c5569cf70)
This is a value of type IO[Unit]: a description of the computation. It has not executed any side effect yet. To run the side effects we need to call unsafeRun:
echo.unsafeRun()
// What is your name?
// Nicolas
// Hello, Nicolas!
IO values happens entirely in the pure world; unsafeRun is the single door to the impure world, opened at the end of the world. The vermilion path is the anti-pattern: executing an effect inside the description leaks impurity into pure code.The deck shows exactly what breaking the boundary looks like — a name = IO.getLine.unsafeRun() line inside the for comprehension:
def program: IO[Unit] = for
_ <- IO.putLine("What is your name?")
name = IO.getLine.unsafeRun() // <-- breaking the boundary!
_ <- IO.putLine(s"Hello, $name!")
yield ()
The damage is threefold: side effects leak into code that should stay pure; effects happen now, not at the end of the world; and testing gets harder because control is lost. The discipline, in one line: "The effects should only be executed at the 'end of the world', and not inside the pure code."
This is the IO monad of chapter 7, section 3 renamed and re-derived, and the DrawNumber game there is exactly this pattern. But the seminar's next move is the one that defines the rest of the chapter: real-world programs typically involve multiple effects (state, I/O, exceptions) that need to be combined and managed together, and one monad handles one effect. Composing effects is "a matter of composition" — and that is where monad stacks enter.
Be able to explain why println(echo) prints an IO(...) value and not a conversation: the type IO[A] is a thunk unsafeRun: () => A, and building the value performs nothing. Also be able to spot boundary-breaking code (an unsafeRun inside a for comprehension or inside a function that returns IO) and to say why it defeats testing and reasoning.
The motivating example is a parser: "a computation that takes an input string and produces either a parsed value or an error if the input is invalid." Its type combines two effects — consuming the input (state) and failing (optionality):
final case class Parser[A](parse: String => Option[(A, String)])
given Monad[Parser] with
def pure[A](a: A): Parser[A] = Parser(input => Some((a, input)))
def flatMap[A, B](fa: Parser[A])(f: A => Parser[B]): Parser[B] = Parser: input =>
fa.parse(input) match
case Some((a, rest)) => f(a).parse(rest)
case None => None
Combinators are then trivially composed into a grammar — here a parser for the language "aab":
def char(c: Char): Parser[Unit] = Parser:
case input if input.nonEmpty && input.head == c => Some(((), input.tail))
case _ => None
def aab(): Parser[Unit] = for
_ <- Parser.char('a')
_ <- Parser.char('a')
_ <- Parser.char('b')
yield ()
val input = "aab"
val result = Parser.aab().parse(input)
println(result) // Output: Some(((), ""))
The observation that drives everything: the parser is structurally identical to a State monad, except that the state transition can fail. Compare the two types:
final case class Parser[A](parse: String => Option[(A, String)])
final case class State[S, A](run: S => (A, S))
The parser is a combination of two effects: State holding the input string as state, and Option representing the possibility of failure. The seminar's wish: "We want to 'stack' these effects together in a modular way, allowing us to reuse and compose them without having to rewrite the logic for each new combination of effects." That wish is answered by monad transformers.
"A monad transformer is a type constructor that takes a monad as an argument and returns a new monad that combines the effects of both monads."
"A monad stack is the combined structure of multiple monads layered on top of each other to represent computations that involve multiple effects."
Transformers allow composing effects in a modular way and provide a unified interface for working with multiple effects together. The notable stacks of the ecosystem are EitherT[M[_], E, A] (error handling), StateT[M[_], S, A] (state manipulation) and OptionT[M[_], A] (optional values). Formally, a transformer is a pair (T, lift): T takes a monad M and produces a new monad T[M, A]; lift injects a computation of the base monad into the transformed monad.
trait MonadTransformer[T[_[_], _]]:
def lift[M[_]: Monad, A](ma: M[A]): T[M, A]
The canonical instance, OptionT, is a one-liner — OptionT[M, A] = M[Option[A]] — and its lift wraps the result in Some:
final case class OptionT[M[_], A](value: M[Option[A]])
given MonadTransformer[OptionT] with
def lift[M[_]: Monad, A](ma: M[A]): OptionT[M, A] = OptionT(ma.map(Some(_)))
Lifting is what lets a base-monad computation live inside the stack. The deck's demo threads an IO computation through OptionT, fails, and observes that the remaining lines never run:
def failAndIO: OptionT[IO, Unit] = for
_ <- IO.putLine("This will fail").lift[OptionT]
_ <- OptionT.fail[IO, Unit]
_ <- IO.putLine("This will never be printed").lift[OptionT]
yield ()
@main def runMonadStack(): Unit =
failAndIO.runOptionT.unsafeRun() match
case Some(_) => println("Unexpected success")
case None => println("Expected failure")
The critical lesson of the section: "The order of monad transformers in a stack matters because it determines how effects are combined and how computations are executed." The two stacks of the deck differ only in which transformer is outermost:
type Stack1[A] = StateT[[V] =>> OptionT[IO, V], String, A]
type Stack2[A] = OptionT[[V] =>> StateT[IO, String, V], A]
Both stacks sit on IO and carry a String state; the difference is what happens on failure. In Stack1 the state transition lives inside the Option: a None erases the whole (state, result) pair, so failure discards state. In Stack2 the Option wraps only the result while the state layer still runs underneath, so failure preserves state — the final state remains observable even when the computation fails.
OptionT over a State[String, _] base.The parser is then re-expressed as a stack — the identity monad as the empty base, State holding the input, OptionT on top for failure — and the same combinators work, now built from StateT primitives lifted into the stack:
type State[S, A] = StateT[Identity, S, A]
type Parser[A] = OptionT[[V] =>> State[String, V], A]
extension [A](parser: Parser[A])
def parse(input: String): (Option[A], String) =
parser.runOptionT.runStateT(input)
// Parser combinators
def fail[A]: Parser[A] = OptionT.fail
def get: Parser[String] = StateT.get.lift
def set(value: String): Parser[Unit] = StateT.set(value).lift
val input = "abc"
val parser: Parser[Unit] = for
_ <- char('a')
_ <- char('b')
_ <- char('c')
yield ()
val (result, remaining) = parser.parse(input)
result match
case Some(_) => println(s"Parsed successfully! Remaining input: '$remaining'")
case None => println("Failed to parse.")
Note the ordering decision embedded in the alias: OptionT over State is Stack2-shaped, so on failure the remaining input is still returned — exactly what a parser wants. Had the layers been reversed, a failing parser would have lost the input, which would be useless for error reporting.
Be able to expand both stacks mentally: Stack1[A] = StateT[OptionT[IO, _], String, A] means the state transition is itself an OptionT[IO, (String, A)], so failure produces nothing at all; Stack2[A] = OptionT[StateT[IO, String, _], A] means the state layer runs and only the result is wrapped, so failure still yields the final state. Given an arbitrary pair of transformers, you should be able to say which effect "wins" on failure — the one whose value erases the others.
The deck is honest about the price of transformers. After the parser success story, it lists three limits of the fixed-stack style — the reasons the seminar moves on to tagless final:
Every function that uses the stack must name it: EitherT[StateT[List, Int, *], Exception, String] in the type of a function that only wants to decrement a counter. The effect representation has become part of the domain code, and swapping the stack means touching every signature. The fix of the next two sections is to stop naming the stack in the business logic at all.
Tagless final is the encoding style that keeps the stack out of the domain logic. The idea, in the deck's words: "Represent programs as interfaces (type classes) instead of concrete syntax trees, and interpret them by providing implementations." The core pattern has three moves:
The deck adds a clarifying warning: "It has nothing to do with monads. It is an encoding style to solve the 'expression problem', not a monadic structure." Monads are one way to instantiate the encoding; the encoding itself is older and more general.
A tagless-final program is a polymorphic program: it does not commit to a concrete effect type, only to the operations it requires. F[_] is left abstract; type-class constraints describe the capabilities the program needs; concrete effect stacks appear only when an interpreter is chosen.
def program[F[_]](using /* required capabilities */): F[Result]
Algebras package domain-specific effects as small interfaces. The running example is a user repository — and the signature deliberately says nothing about how users are stored: it only states which operations are available for any effect F. The algebra belongs to the domain, not to a monad-transformer stack.
trait UserRepository[F[_]]:
def get(id: UUID): F[Option[User]]
def save(user: User): F[Unit]
def changeUserEmail(id: UUID, newEmail: String): F[Unit]
Business logic depends on the algebra and on generic capabilities such as Monad. The sign-up program below uses pure[F] — the monadic unit of chapter 7 — to lift a pure value into the abstract effect, and then sequences the repository save. The function signature tells us exactly what the program needs; if the runtime changes, this function stays the same.
def signup[F[_]: Monad](name: String, email: String)(using
repo: UserRepository[F]
): F[Unit] = for
id <- UUID.randomUUID().pure[F]
_ <- repo.save(User(id, name, email))
yield ()
The same program is interpreted in different concrete effect types. In production the repository is backed by a real database; in tests it is backed by a map. Only the interpreter changes — the program is reused unchanged:
given UserRepository[ProductionRunner] = ???
given UserRepository[InMemoryRunner] = ???
val prod: ProductionRunner[Unit] =
signup[ProductionRunner]("Alice", "[email protected]")
val test: InMemoryRunner[Unit] =
signup[InMemoryRunner]("Bob", "[email protected]")
The production interpreter wires the repository to the real world — typically an IO-based runner with database transactions. The ??? is the seam: the given instance is provided by the production runtime, not by the domain code.
// production: real database access, real failure modes
given UserRepository[ProductionRunner] = ???
// get -> SELECT ... WHERE id = ?
// save -> INSERT / UPSERT ...
// changeUserEmail -> UPDATE ...
val prod: ProductionRunner[Unit] =
signup[ProductionRunner]("Alice", "[email protected]")
The same signup source text now performs real persistence when run. No domain code was touched to reach this point.
The in-memory interpreter keeps users in a Map: tests exercise the same program with no database, no setup, no cleanup — only the interpreter differs.
// tests: a map-backed repository, deterministic assertions
given UserRepository[InMemoryRunner] = ???
// get -> map.get(id)
// save -> map.updated(user.id, user)
// changeUserEmail -> map.updated(id, user.copy(email = newEmail))
val test: InMemoryRunner[Unit] =
signup[InMemoryRunner]("Bob", "[email protected]")
This is the module-type discipline of chapter 6, section 4 applied to effects: one contract, many implementations, clients abstract over the contract.
F[_] and depends only on what it needs, and interpreters pick the runtime at the end of the world. The bottom band is the fixed-stack contrast of section 4.The type-class machinery of chapter 6, section 5 (given/using, context bounds) is what makes the encoding readable: signup[F[_]: Monad] says "any effect that is a monad", and (using repo: UserRepository[F]) says "any runtime that provides a repository". The same program runs against the production database and against an in-memory map — the interpreter is the only thing that changes. This is the module-type pattern of chapter 6 at the effect level.
MTL (the Monad Transformer Library, in Scala the cats-mtl library) provides "a set of type classes and combinators to work with monad transformers in a more modular and composable way." Its supported transformers cover the usual zoo — EitherT, Kleisli, IorT, OptionT, ReaderWriterStateT, StateT, WriterT — and it is added to an SBT project with one line:
libraryDependencies += "org.typelevel" %% "cats-mtl" % "<version>"
The deck's point is that MTL is a middle path: it still works with concrete stacks, but the boilerplate of explicit types and manual lifting is pushed into type-class constraints. Compare the two versions of the same "decrement the state, or fail" function.
First, the explicit-stack version — "we need to make the types explicit — mostly to help the compiler — but also to make it clear to the reader what effects are being used." Note the plumbing: EitherT.liftF to enter the stack, EitherT.leftT to fail, type lambdas everywhere:
def decrementStateBoilerplate: EitherT[StateT[List, Int, *], Exception, String] =
for
currentState <- EitherT.liftF(StateT.get[List, Int])
result <- if (currentState < 0) then
EitherT.leftT[[V] =>> StateT[List, Int, V], String](
new Exception("State cannot be decremented below zero")
)
else
EitherT.liftF(StateT.set[List, Int](currentState - 1))
.as("State decremented successfully!")
yield result
Then the type-class version: capabilities are expressed as constraints — Stateful[F, Int] gives get/set for any F, MonadError[F, Exception] gives raiseError — and the function is generic in F:
def decrementState[F[_]](using
Stateful[F, Int], MonadError[F, Exception]
): F[String] =
for
currentState <- Stateful.get
result <- if (currentState > 0) then
Stateful.set(currentState - 1) *> "State decremented successfully!".pure
else
MonadError[F, Exception].raiseError(
new Exception("State cannot be decremented below zero")
)
yield result
The body no longer mentions any transformer: Stateful.get, *> (sequence and keep the right result), .pure, raiseError — pure capability vocabulary. This is the tagless-final idea of section 5 applied inside a library: the type classes are the algebras.
The sign-up example is completed. The domain effects are declared as algebras — here an email service joins the repository of section 5:
trait EmailService[F[_]]:
def sendEmail(to: String, subject: String, body: String): F[Unit]
def signup[F[_]: Monad](name: Usename, email: Email)(using
repo: UserRepository[F],
emailService: EmailService[F],
raiseError: Raise[F, String],
): F[Unit] = ...
Note Raise[F, String]: the capability to fail with a String error, requested as a type-class constraint. Interpreters are then provided generically — the in-memory repository is defined for any F that can carry the user database in its state:
object UserRepository:
type UserDb = Map[UUID, User]
given inMemoryRepository[F[_]: Monad](using
state: Stateful[F, UserDb]
): UserRepository[F] with
def get(id: UUID): F[Option[User]] = state.get.map(_.get(id))
def save(user: User): F[Unit] = state.modify(_.updated(user.id, user))
def changeUserEmail(id: UUID, newEmail: String): F[Unit] =
state.modify: s =>
s.get(id) match
case Some(user) => s.updated(id, user.copy(email = newEmail))
case None => s // No change if user not found
And finally, at the end of the world, the concrete stack is chosen once — a single type alias that combines EitherT (errors as String), StateT (the user database), and IO (the base monad) — and the program is run on it:
type Eff[A] = EitherT[[V] =>> StateT[IO, Map[UUID, User], V], String, A]
val initialUsers: Map[UUID, User] = Map.empty
def run: IO[Unit] = signup[Eff]("Alice", "[email protected]")
.value
.run(initialUsers)
.flatMap:
case (newState, Right(_)) =>
IO.println("User signed up successfully! New state: " + newState)
case (_, Left(error)) =>
IO.println(s"Error: $error")
The stack appears exactly once — in the alias — and every function in between was written against capabilities. The .value unwraps the EitherT, .run(initialUsers) feeds the initial state, and the final flatMap pattern-matches the two channels: the updated state and the result.
Compare the three encodings seen so far: the raw OptionT/StateT stack of section 3 (explicit types, manual lifts), the tagless-final algebras of section 5 (abstract F[_], interpreters at the end), and MTL (generic capability type classes with a concrete stack chosen at the end). All three appear in real codebases; the seminar's arc — and the exam discussion — is being able to say when each is appropriate.
The second seminar part asks a sharper question: what changes after tagless final? The answer is that tagless final keeps capabilities abstract, but the code is still written in a monadic shape — values are wrapped, sequencing goes through flatMap, and the implementation is constrained by the chosen effect interface. The direct-style promise is different:
"Keep the required effects explicit in the type, but write the implementation in a style that looks much closer to ordinary imperative Scala."
The same requirements can be written in both shapes. Monadic shape first — note how the effects (config, logging, errors) are requested as using capabilities of F, and the body is a for comprehension:
def op[F[_]: MonadThrow](id: Int)(using C: Config[F], L: Logger[F]): F[Result] = for
config <- C.config
_ <- L.info(s"Processing $id")
result <- if id < 0 then
InvalidIdError.raiseError[F, Result]
else
compute(config, id).pure[F]
yield result
And the same function in direct style: the capabilities Config and Logger are still visible in the signature — as context parameters — but the body is step-by-step code, and failure is a plain Left in the result type:
def op(id: Int): (Config, Logger) ?=> Either[Error, Result] =
val config = Config.config
Logger.info(s"Processing $id")
if id < 0 then Left(InvalidIdError)
else Right(compute(config, id))
The deck's trade-off table is worth quoting in full:
| Direct style | Monadic style |
|---|---|
| Closer to ordinary Scala, less ceremony on the happy path | Stronger compositional structure, sequencing kept explicit |
| Easier local reasoning | Very strong composition story |
| Less implementation boilerplate | Mature ecosystem and libraries |
| Reads like business logic | Explicit sequencing discipline |
| Higher-order safety is more delicate | Large stacks can become harder to read |
The shift underneath both columns: from effects as results (a monad is the effect, returned as a value) to effects as capabilities (the code requires the right to perform the effect, via a context parameter).
The machinery is the given/using system of chapter 6, section 5: "We can leverage contextual abstractions to encode effects directly in the type system, without wrapping values in monads. Instead of returning an effect value, we require the capability that authorizes the effect." The IO capability is a plain trait, and the companion object exposes context-taking methods:
trait IO:
def write(content: String): Unit
def read[T](f: Iterator[String] => T): T
object IO:
def write(content: String)(using io: IO): Unit =
io.write(content)
def read[T](f: Iterator[String] => T)(using io: IO): T =
io.read(f)
Using the capability is just putting it in the signature, and the body is ordinary code:
def processFile(path: String)(using IO): Unit =
val content = IO.read: lines =>
lines.mkString("\n")
IO.write(s"File content:\n$content")
Execution becomes "provide the handler, then run the direct-style code" — the end of the world in direct style:
def handle[A](program: IO ?=> A): A = ???
@main def run(): Unit =
IO.handle:
processFile("data.txt")
In monadic style all effects are executed at the end of the world, when the program is run with a concrete handler; even in direct style the effects are still executed at the end of the world — the code just looks more like ordinary Scala. The general shape is a handle that installs a given and runs the program:
def myProgram(input: String)(using Effect): Unit =
// Do some work with Effect
def handle[A](program: Effect ?=> A): A =
given Effect with
// Provide the implementation of Effect
program
@main def run(): Unit = handle { myProgram("input") }
F[Result] and as a context-taking direct function; the capability encoding via a trait and a using parameter; and the two end-of-the-world mechanisms: handle for providing handlers, throws/CanThrow for keeping failure in the contract.The seminar returns to the sign-up example and re-encodes it. The required capabilities are now plain traits whose operations return plain values:
type UserState = Map[UUID, User]
trait UserRepository:
def get(id: UUID): Option[User]
def save(user: User): Unit
def changeEmail(id: UUID, newEmail: String): Unit
trait EmailService:
def sendEmail(to: String, subject: String, body: String): Unit
And the implementation is direct style: capabilities in the signature, ordinary statements in the body — "signing up will need a repository, and an email service" is what the signature already says.
def signup(name: Username, email: Email)(using
UserRepository,
EmailService
): Unit = ???
The key move for errors: "Instead of saying 'this computation produces an error effect', say 'this code requires the capability to throw that error'." The capability is CanThrow[E], and throws is surface syntax for requiring it:
erased class CanThrow[-E <: Exception]
infix type throws[R, -E <: Exception] = CanThrow[E] ?=> R
The two definitions of the same validator are literally the same requirement in two notations:
enum AuthError(msg: String) extends Exception(msg):
case InvalidEmail extends AuthError("invalid email")
case EmailAlreadyExists extends AuthError("email already exists")
def validate(email: Email): Unit throws AuthError =
if !email.contains("@") then throw InvalidEmail()
def validate2(email: Email)(using CanThrow[AuthError]): Unit =
if !email.contains("@") then throw InvalidEmail()
Sign-up with checked failure is then direct-style code plus one capability in the signature:
def signup(name: Username, email: Email)(using
UserRepository, EmailService, CanThrow[AuthError]
): User =
validate(email)
val user = User(UUID.randomUUID(), name, email)
repo.save(user) // may fail with EmailAlreadyExists
emailService.sendEmail(...)
user
The implementation is still direct style; the only addition is a capability saying that failures are part of the function's contract.
Direct style has a subtle failure mode that monadic style does not: a returned closure can outlive the scope that granted its capabilities. In the deck's deferredSignup example, the function returns () => User from inside a try that catches AuthError — but the closure still depends on the temporary throwing capability once the try is gone, so the effect has escaped its scope. Be able to explain why this forces the direct-style story to add capture checking (next section), and why monadic style never has this problem (the effect is a value, and the value cannot outlive anything).
Direct style "is pleasant", the deck concedes, "but preserving scopes requires capture checking." Capture checking in Scala 3 (experimental, import language.experimental.captureChecking) "tracks which values and closures depend on which capabilities, so that capabilities cannot silently escape the region where they are valid." The deck calls it "the missing ingredient for safer direct-style effects", especially useful for resources, handlers, continuations, and scoped I/O.
The example is a scoped resource. At first glance nothing is wrong with this function:
def usingLogFile[T](op: FileOutputStream => T): T =
val logFile = FileOutputStream("log")
val result = op(logFile)
logFile.close()
result
But it "will crash if higher order functions are used":
val later = usingLogFile { file => (x: Int) => file.write(x) }
later(10) // crash
The returned closure captures file, but the file is already closed when the closure runs. The fix is to make the file a capability: FileOutputStream^ means the value is a capability whose lifetime the compiler tracks, and results are verified not to carry it outside its valid scope.
def usingLogFile[T](op: FileOutputStream^ => T): T =
val logFile = FileOutputStream("log")
val result = op(logFile)
logFile.close()
result
Eagerness matters. Using the capability inside an eager collection is safe — the writes happen immediately while f is still valid. Using it inside a lazy collection is unsafe — the work is delayed, so the file capability may be used too late:
// Safe: eager evaluation
val xs = usingLogFile: f =>
List(1, 2, 3).map: x =>
f.write(x)
x * x
// Unsafe: delayed evaluation
val xs = usingLogFile: f =>
LazyList(1, 2, 3).map: x =>
f.write(x)
x * x
To track capabilities, the compiler annotates types with the set of capabilities they capture:
T^{C1, C2, ...}—Tis a normal type;C1, C2, ...are the capabilities captured by values of typeT.
Two rules govern how capture sets behave. Subtyping: pure types are subtypes of capturing types — T <: ^{C} T for any capture set C — and for capturing types, smaller capture sets produce subtypes: ^{C1} T1 <: ^{C2} T2 when C1 <: C2 and T1 <: T2. Instantiation: a type T annotated with capabilities C1, C2, ... can only be instantiated where those capabilities are in scope.
The function syntax encodes the same distinction. A -> B is a pure function: it captures nothing, has an empty capture set, and can be passed around without depending on hidden capabilities. A => B is an impure function: it may close over arbitrary capabilities — use it when purity is not guaranteed. The precise capture set can be written explicitly: A ->{f} B captures exactly f and nothing else. Context functions follow the same idea: A ?-> B is a pure context function, A ?=> B may capture arbitrary capabilities.
Applied to the logging example, the type of the escaped closure records the crime:
val res: Int ->{f} Unit = usingLogFile: f =>
(x: Int) => f.write(42); x * x
res(10) // error: capability f is not in scope here
The compiler rejects the call site with a message that names the mechanism:
|The expression's type Int => Unit is not allowed to capture the root capability.
|This usually means that a capability persists longer than its allowed lifetime
When the type Int ->{f} Unit is instantiated, its capture set is not empty; the compiler checks that the required capabilities are in scope, and here f is not — so the code is rejected.
The same escape exists for handlers. If read returns a value still tied to the handler, that value can outlive the handler and fail later with errors such as Stream Closed:
trait IO:
def println(content: String): Unit
def read[R](combine: Iterator[String] => R): R
type EffectIO[R] = IO ?=> R
def unsafeReadFile: EffectIO[Iterator[String]] =
IO.read(identity)
Making the returned iterator a capability closes the hole: now the compiler can track its lifetime and prevent it from being used after the handler is gone.
trait IO:
def println(content: String): Unit
def read[R](combine: Iterator[String]^ => R): R
object IO:
def handle[R](program: IO ?=> R): R =
given IO with
// Provide the implementation of IO
program
Capture checking solves one problem; a second one remains. "Capture checking asks: 'can this capability outlive its scope?' Separation checking asks: 'can two references alias the same mutable authority?'" Mutable capabilities extend ExclusiveCapability, and separation checking (experimental, import language.experimental.separationChecking) verifies that write authority is never unsafely shared:
import language.experimental.captureChecking
import language.experimental.separationChecking
trait Mutable extends ExclusiveCapability
class Matrix(nrows: Int, ncols: Int) extends Mutable:
update def setElem(i: Int, j: Int, x: Double): Unit = ???
def getElem(i: Int, j: Int): Double = ???
The signature of multiply reads as a contract: a and b are read-only inputs, c is the exclusive mutable output position. The compiler guarantees that multiply cannot update a or b, and that c must be distinct from both — preventing accidental aliasing:
def multiply(a: Matrix, b: Matrix, c: Matrix^): Unit =
???
The seminar's closing remark ties separation back to the running example: "CurrentUserState was intentionally introduced as a mutable capability: separation checking is the mechanism that keeps this sort of write authority from being unsafely aliased."
^ and forbids aliasing it with read-only inputs.Lifetime and aliasing are different dangers. Capture checking asks whether a capability can outlive its scope and answers it by annotating every type with its capture set; separation checking asks whether two references can alias the same mutable authority and answers it by marking exclusive capabilities. Together they turn the pleasant direct-style code of section 7 into a safe one: the type system itself rejects the deferredSignup escape and the multiply(a, b, a) aliasing bug before they run.
The seminar ends by compressing everything into three stages, and the deck's "why the ending matters" is worth reading carefully:
"Three stages of the story: monads encode effects in values · direct style encodes effects as capabilities · capture checking makes scoped capabilities safe."
"Why the ending matters: the sign-up flow reads like ordinary code · CanThrow keeps failure in the contract · separation checking protects mutable authority such as current-user state."
Each stage answers the previous one's weakness. Monads package any one effect as a pure value — but combining effects needs stacks, and fixed stacks leak into business logic. Tagless final (and MTL) abstract over the stack so the domain never names it — but the code is still monadic in shape. Direct style writes the business logic as ordinary Scala with effects as capabilities — but then scoped capabilities can escape, which is exactly what capture and separation checking close off. The first part of the seminar summarised the first two rungs of the same ladder in its wrap-up: "Monads are a powerful way to model effects; monad stacks allow us to combine multiple effects together"; "we can write our programs in terms of capabilities and provide different interpreters for testing and production"; and MTL "provides a set of type classes and combinators to work with monad transformers in a more modular and composable way."
Chapter 7 ended on the promise of "effects beyond monads", and this chapter delivers it as a three-stage story. For the exam, be able to place any effect-handling code you are shown on this ladder: raw monad (one effect, value-encoded), monad stack (several effects, order-sensitive), tagless final/MTL (capabilities abstract, interpreter at the end), direct style (capabilities in context, ordinary-looking code), and capture/separation checking (the safety layer that makes direct style trustworthy).
The seminar ships a companion repository — https://github.com/nicolasfara/asmd-effect-systems-overview-code — containing all the code of both parts as a runnable SBT project. This section follows the lab convention of the course chapters and frames the operational steps plus the R&D tasks you can develop into an exam discussion.
IO examples: print echo and observe the IO(Lambda...) description; call unsafeRun and observe the side effects — then remove the boundary-breaking line from program and confirm the pure version behaves identically when run at the end of the world.runMonadStack and the parser example; then flip the stack ordering of the parser (StateT over OptionT instead of the other way) and observe how the remaining input behaves on failure.EitherT[StateT[IO, ...]]), tagless final (signup[F[_]: Monad] with two interpreters), direct style ((using UserRepository, EmailService, CanThrow[AuthError])) — and keep the tests green across all three.import language.experimental.captureChecking) and verify that the escaped-closure and LazyList examples are rejected while the eager ones compile.| Task | What it asks |
|---|---|
| STACK-ORDER | Take a computation with at least three effects (e.g. a parser with state, failure and logging) and implement it under both layer orders of section 3. Characterise precisely what each order preserves on failure, and derive a rule of thumb for choosing the order (e.g. "the outermost transformer decides what survives a short-circuit"). Verify the behaviour with property-based tests following the MONAD-VERIFIER methodology of chapter 7. |
| TAGLESS-SIGNUP | Complete the sign-up feature of section 5: define UserRepository[F[_]] and EmailService[F[_]], write the polymorphic signup program, and provide at least two interpreters (production on IO + database, in-memory on a Map). Show that the same program source is reused, and test the in-memory interpreter against the same suite you would run in production — the module-type discipline of chapter 6, section 4 made concrete. |
| MTL-REFACTOR | Take the fixed-stack decrementStateBoilerplate of section 6 and rewrite it with cats-mtl type classes (Stateful, MonadError, Raise). Measure what disappears: type annotations, liftF/leftT plumbing, type lambdas. Then add a third capability (e.g. logging via Tell) and show that the MTL version changes less than the explicit-stack version. |
| CAPTURE-GUARD | Reproduce the usingLogFile example of section 8 under captureChecking. Enumerate the variants the compiler accepts and rejects (eager vs lazy, closure returned vs used inside, handler-escaped iterator vs capability-annotated iterator), and for each rejection explain — with the actual compiler error — which capture rule fired. This is the empirical counterpart of the direct-style trade-off of section 7. |
This seminar is the bridge between the monadic world of chapter 7 and the modelling chapters that follow: the discipline of representing behaviour as values is the same one you will use for transition systems in chapter 9. A strong presentation picks one task above, implements it in at least two encodings, and is ready to discuss the trade-offs — order sensitivity, boilerplate, capability safety — rather than just the happy path.
A side effect is any change in program state or observable behavior beyond the function's return value. Pure functions only depend on their input arguments and return a value without modifying state or performing I/O; impure functions may modify state, perform I/O, or have other observable behaviours. "Side effects are lies": the function promises to do one thing, but it also does other hidden things — behaviour depends on more than the signature says.
An effect system is a kind of type system that tracks the effects of computations, allowing developers to reason about and control them more effectively. The examples in the wild are Java's checked exceptions (failure in the signature), monads in Haskell (effects as values), and Koka's algebraic effects (effect handlers as first-class constructs).
Instead of performing effects directly, represent an effectful computation as a value such as M[A], and use a common interface to combine these values; that interface is the idea of a monad. Ad-hoc mechanisms do not scale (checked exceptions only talk about failures; randomness, state, logging and I/O each need different mechanisms), while the monad keeps the language pure, makes effects explicit in the type, and gives one uniform way to sequence and compose effectful computations.
We clearly separate the description of a computation from its execution: building IO values performs no side effect, and effects are executed only at the end of the world, via unsafeRun, never inside pure code. Breaking the boundary looks like name = IO.getLine.unsafeRun() inside a for comprehension: side effects leak into pure code, effects happen now instead of at the end of the world, and testing gets harder because control is lost.
A monad transformer is a type constructor that takes a monad as an argument and returns a new monad that combines the effects of both monads; a monad stack is the combined structure of multiple monads layered on top of each other. A transformer can be seen as a pair (T, lift): T produces T[M, A] from M, and lift injects computations from the base monad into the transformed monad. The canonical example is OptionT[M, A] = M[Option[A]] with lift(ma) = OptionT(ma.map(Some(_))).
Order determines how effects are combined and how computations are executed. Stack1[A] = StateT[[V] =>> OptionT[IO, V], String, A] puts the state transition inside the Option: a None erases the whole (state, result) pair, so failure discards state. Stack2[A] = OptionT[[V] =>> StateT[IO, String, V], A] wraps only the result in the Option while the state layer still runs, so failure preserves state. The parser uses the Stack2 shape deliberately: on failure the remaining input is still returned.
Manual lifting: lift operations complicate the code with boilerplate and stack changes demand extensive rewriting. Principle of least power: fixing the stack forces unnecessary capabilities onto parts of the application. Encapsulation violation: code is tightly coupled to a specific effect modelling, which severely hinders future changes.
Tagless final represents programs as interfaces (type classes) instead of concrete syntax trees, and interprets them by providing implementations: define algebras that describe needed capabilities, write programs that depend on those algebras and on generic capabilities, and provide interpreters for specific effect types. It is an encoding style to solve the expression problem, not a monadic structure — monads are just one way to instantiate it.
The algebra UserRepository[F[_]] declares the operations (get, save, changeUserEmail) for any effect F, saying nothing about storage. The program signup[F[_]: Monad](name, email)(using repo: UserRepository[F]): F[Unit] is polymorphic in F and commits only to the capabilities it needs. Interpreters choose the runtime: given UserRepository[ProductionRunner] and given UserRepository[InMemoryRunner] let the same program run against a real database or a map — the program is reused, only the interpreter changes.
MTL (cats-mtl in Scala) provides a set of type classes and combinators to work with monad transformers in a more modular and composable way. Instead of naming the full stack in every signature (EitherT[StateT[List, Int, *], Exception, String] with liftF/leftT plumbing), capabilities are expressed as type class constraints such as Stateful[F, Int], MonadError[F, Exception] and Raise[F, String], so the function is written generically and the concrete stack appears once, at the end of the world.
Keep the required effects explicit in the type, but write the implementation in a style much closer to ordinary imperative Scala: def op(id: Int): (Config, Logger) ?=> Either[Error, Result] instead of a monadic F[Result] built by flatMap. Direct style is closer to ordinary Scala with easier local reasoning and less boilerplate, but higher-order safety is more delicate; monadic style has a stronger composition story and a mature ecosystem, but sequencing is more ceremony and large stacks become hard to read.
Instead of returning an effect value, require the capability that authorises the effect: trait IO { def write(content: String): Unit; def read[T](f: Iterator[String] => T): T }, with companion methods taking (using io: IO). Using a capability is just a context parameter in the signature — def processFile(path: String)(using IO): Unit — and execution becomes "provide the handler, then run the direct-style code": def handle[A](program: IO ?=> A): A.
throws is just syntax: infix type throws[R, -E <: Exception] = CanThrow[E] ?=> R. Saying "this code requires the capability to throw that error" replaces "this computation produces an error effect". validate(email): Unit throws AuthError and validate2(email)(using CanThrow[AuthError]): Unit mean the same thing; the body throws as in ordinary code, and failure stays part of the function's contract.
A returned closure can outlive the try/catch that established the throwing capability. In deferredSignup, the function returns () => User from inside a try that catches AuthError; the closure still depends on the temporary throwing capability once the try is gone, so the effect has escaped its scope. This is why direct style needs capture checking: some capabilities are ephemeral and must be tracked.
Capture checking (experimental in Scala 3) tracks which values and closures depend on which capabilities, so that capabilities cannot silently escape the region where they are valid. The compiler annotates types with a capture set: T^{C1, C2, ...} means T is a normal type and C1, C2, ... are the capabilities captured by values of type T. Pure types are subtypes of capturing types, smaller capture sets produce subtypes, and instantiating a type requires its capabilities to be in scope.
A -> B is a pure function: it captures nothing (empty capture set) and can be passed around without depending on hidden capabilities. A => B is a function that may close over arbitrary capabilities — use it when purity is not guaranteed. A ->{f} B is a pure function with an explicit capture set: it captures exactly the capability f and nothing else. Context functions follow the same idea: A ?-> B pure, A ?=> B possibly capturing.
Eager evaluation (List) performs the writes immediately, while the file capability f is still valid inside the scope of usingLogFile — safe. Delayed evaluation (LazyList) defers the work, so the capability may be used too late, after the file is closed — unsafe. Capture checking rejects the delayed escape at compile time.
Separation checking controls aliasing when mutable capabilities are involved (capture checking controls lifetime and escape). Mutable capabilities extend ExclusiveCapability. In multiply, a and b are read-only inputs and c is the exclusive mutable output position: the compiler guarantees multiply cannot update a or b, and that c must be distinct from both, preventing accidental aliasing.
Monads encode effects in values; direct style encodes effects as capabilities; capture checking makes scoped capabilities safe. The ending matters because the sign-up flow reads like ordinary code, CanThrow keeps failure in the contract, and separation checking protects mutable authority such as current-user state.