Part B — High-level specification and programming · Chapter 8

Effect systems: transformers, tagless final and capabilities

~40 min read5 interactive widgets6 plates

In this chapter

  1. Side effects and the effect-system idea
  2. Monadic effects: IO and the end of the world
  3. Composing effects: monad transformers and stacks
  4. Why fixed stacks do not scale
  5. Tagless final encoding
  6. MTL: capabilities as type classes
  7. Direct style: effects as capabilities
  8. Capture checking and separation checking
  9. The three stages of the story
  10. Companion code and R&D directions
  11. Test your knowledge

1. Side effects and the effect-system idea

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:

Type systems make the implicit explicit

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)

From ad-hoc effects to a general model

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:

Key idea — the answer is the monad

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.

2. Monadic effects: IO and the end of the world

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 famous monads and the IO case

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!

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."

Editor's note — one effect is easy, many are not

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.

For the exam

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.

3. Composing effects: monad transformers and stacks

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(((), ""))

A parser is a stack

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 order matters

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.

Parsing with the stack

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.

For the exam

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.

4. Why fixed stacks do not scale

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:

Diagnosis — the stack leaks into the business logic

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.

5. Tagless final encoding

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.

The core idea: a polymorphic program

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 ()

Interpreters choose the runtime

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.

Key idea — abstract over the capability, not the stack

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.

6. MTL: capabilities as type classes

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.

Domain logic on top of MTL

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.

Editor's note — the ladder of abstraction

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.

7. Direct style: effects as capabilities

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 styleMonadic style
Closer to ordinary Scala, less ceremony on the happy pathStronger compositional structure, sequencing kept explicit
Easier local reasoningVery strong composition story
Less implementation boilerplateMature ecosystem and libraries
Reads like business logicExplicit sequencing discipline
Higher-order safety is more delicateLarge 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).

Encoding capabilities with contextual abstractions

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") }

The sign-up feature in direct style

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 = ???

Failure as a capability

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.

For the exam — the higher-order caveat

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).

8. Capture checking and separation checking

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 motivating bug: a file that outlives its scope

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

Capture sets

To track capabilities, the compiler annotates types with the set of capabilities they capture:

T^{C1, C2, ...}T is a normal type; C1, C2, ... are the capabilities captured by values of type T.

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.

Direct-style IO can leak too — and capture-aware IO fixes it

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

Separation checking: aliasing of mutable authorities

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."

Key idea — two questions, two checkers

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.

9. The three stages of the story

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."

Key idea — effects beyond monads

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).

10. Companion code and R&D directions

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.

Operational steps

R&D tasks

TaskWhat it asks
STACK-ORDERTake 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-SIGNUPComplete 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-REFACTORTake 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-GUARDReproduce 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.
For the exam

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.

Test your knowledge

What is a side effect, and why does the seminar call side effects "lies"?

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.

What is an effect system, and which three examples "in the wild" does the seminar give?

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).

What is the general model proposed for effects, and why is the monad the answer?

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.

What is the end-of-the-world principle, and what does breaking the boundary look like?

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.

What is a monad transformer, and what is a monad stack?

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(_))).

Why does the order of a monad stack matter? Compare the two stacks of the lecture.

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.

What are the three limits of fixed monad stacks?

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.

What is tagless final encoding, and why does the deck say it "has nothing to do with monads"?

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.

In tagless final, how do algebras, programs and interpreters relate in the sign-up example?

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.

What does MTL provide, and how does it remove boilerplate compared to explicit stacks?

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.

What is the direct-style promise, and how does direct style compare to monadic style?

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.

How are effects encoded as capabilities with contextual abstractions?

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.

What is throws, and how is it related to CanThrow?

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.

What is the higher-order caveat of direct-style failure?

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.

What is capture checking, and what does the notation T^{C1, C2, ...} mean?

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.

What is the difference between A -> B, A => B and A ->{f} B?

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.

Why does eager vs lazy evaluation matter for scoped capabilities?

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.

What is separation checking, and what does multiply(a: Matrix, b: Matrix, c: Matrix^) guarantee?

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.

What are the three stages of the effects story, and why does the ending matter?

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.