TL;DR — Algebraic effects let you describe, compose, and handle side‑effects directly, removing the need for deep monad‑transformer stacks. They reduce boilerplate, improve modularity, and keep type inference tractable, making them a pragmatic replacement for most use‑cases where monad transformers were previously required.

Effectful programming is a cornerstone of modern functional languages. For years, monad transformers have been the go‑to technique for layering effects such as state, logging, and error handling. However, the transformer approach brings a steep learning curve, verbose type signatures, and a brittle stack that is hard to refactor. Algebraic effects—originally formalised in the early 2000s and now implemented in languages like Haskell (Polysemy, Eff), OCaml, and Koka—offer a composable abstraction that sidesteps these pain points.

In this post we will:

  • Review the practical problems caused by monad transformers.
  • Introduce algebraic effects and their core concepts.
  • Show, with concrete code, how effects replace transformer stacks.
  • Discuss performance, type‑inference, and migration strategies.

The Problem with Monad Transformers

Boilerplate and Stack Management

A typical Haskell application that needs Reader, State, Except, and IO might define a stack like:

type AppM = ReaderT Config (StateT AppState (ExceptT AppError IO))

Every function that touches any effect must thread this stack through its type:

getConfig :: AppM Config
getConfig = ask

modifyState :: (AppState -> AppState) -> AppM ()
modifyState f = lift $ modify f
  • Verbosity – Each new effect adds a new layer, exploding the type synonym.
  • Liftinglift (or liftIO) is required at every boundary, creating “lifting boilerplate” that obscures intent.
  • Ordering constraints – Changing the order of transformers can break existing code because the underlying Monad instance changes.

A real‑world codebase can quickly reach a point where the stack is a tangled mess of ReaderTStateTExceptTWriterTIO. Refactoring such a stack is risky and time‑consuming.

Limitations in Expressiveness

Monad transformers excel when effects are static and global. However, they struggle with:

  • Dynamic effect sets – Adding an effect only for a specific sub‑computation forces you to lift the entire stack, even if the new effect is local.
  • Scoped effects – Implementing a “local” resource (e.g., a temporary logger) often requires MonadMask tricks or custom transformer layers.
  • Higher‑order effects – Effects that themselves manipulate other effects (e.g., a Coroutine that yields control) are cumbersome to model with transformers.

These limitations manifest as code that either over‑engineers a solution or resorts to unsafe unsafePerformIO tricks.


Algebraic Effects: A Primer

Algebraic effects separate effectful operations (the signature) from handlers (the implementation). An effect is declared once, and any number of handlers can interpret it differently.

Handlers and Effectful Computations

In Haskell, the Polysemy library illustrates this pattern:

{-# LANGUAGE GADTs, TemplateHaskell #-}
import Polysemy

-- Declare an effect
data Logger m a where
  LogMsg :: String -> Logger m ()

makeSem ''Logger

-- Use the effect in a computation
program :: Member Logger r => Sem r ()
program = do
  logMsg "Starting computation"
  -- ... other pure code ...
  logMsg "Finished"

The same program can be run with different handlers:

runConsoleLogger :: Member (Embed IO) r => Sem (Logger ': r) a -> Sem r a
runConsoleLogger = interpret $ \case
  LogMsg msg -> embed $ putStrLn ("[LOG] " ++ msg)

runSilentLogger :: Sem (Logger ': r) a -> Sem r a
runSilentLogger = interpret $ \case
  LogMsg _ -> pure ()

The effect signature (Logger) is declared once; the handler decides whether to print, discard, or forward messages.

Implementations in Other Languages

effect Log : string -> unit

let log msg = perform (Log msg)

let run_logger f =
  match f () with
  | v -> v
  | effect (Log msg) k -> 
      print_endline ("[LOG] " ^ msg);
      continue k ()
  • Koka – A language built around algebraic effects, where handlers are first‑class citizens. Example from the Koka tutorial:
function log(msg: string) : () { 
  perform Log(msg) 
}

function main() : () {
  handler {
    case Log(msg) -> stdio.println("[Koka] " ++ msg); resume()
    case Return(v) -> v
  } {
    log("Hello, world!")
  }
}

These examples demonstrate that algebraic effects are not tied to a single library; they are a language‑level abstraction that can be expressed in many ecosystems.


How Algebraic Effects Eliminate the Need for Monad Transformers

Direct Composition

With algebraic effects, you compose effects rather than monad transformers. The type of a computation lists the required effects, not a concrete stack:

type App = '[Logger, State AppState, Reader Config, Embed IO]

runApp :: Config -> AppState -> Sem App a -> IO (Either AppError a)
runApp cfg st = runM .          -- interpret Embed IO
                runReader cfg .-- interpret Reader
                evalState st . -- interpret State
                runConsoleLogger -- interpret Logger
                ...               -- other handlers

Notice the absence of ReaderT, StateT, ExceptT, etc. The Sem monad carries a list of effects, and each handler interprets one slice. Adding a new effect is as easy as adding it to the list and providing a handler—no stack reshuffling required.

Scoped Effects and Modularity

Because handlers are first‑class, you can locally install an effect:

withTempLogger :: (Member Logger r) => (String -> IO ()) -> Sem r a -> Sem r a
withTempLogger sink = interpret $ \case
  LogMsg msg -> embed $ sink msg

Calling code can wrap a sub‑computation with withTempLogger without affecting the rest of the program:

program = do
  logMsg "Global start"
  withTempLogger (writeFile "temp.log") $ do
    logMsg "Only in temporary file"
  logMsg "Global end"

This pattern replaces ad‑hoc local functions or monad‑transformer tricks, delivering true lexical scoping for effects.

Eliminating Boilerplate

Consider an effectful function that needs both logging and state:

increment :: (Member Logger r, Member (State Int) r) => Sem r ()
increment = do
  modify (+1)
  newVal <- get
  logMsg ("Counter is now " ++ show newVal)

No lift calls are required; the effect list handles the necessary plumbing automatically. The compiler infers the minimal constraints (Member Logger r, Member (State Int) r), keeping signatures readable.


Performance and Type Inference Considerations

Zero‑Cost Abstractions

Polysemy’s interpreter is implemented using fusion and GHC rewrite rules that inline handlers, yielding performance comparable to hand‑written monad stacks. Benchmarks in the Polysemy paper (GitHub benchmark suite) show overhead of less than 5 % for typical workloads.

Type Inference Remains Predictable

One fear is that a list of effects could explode the type system. In practice:

  • The Member constraint is a type‑level list lookup, which GHC resolves efficiently.
  • Effects are open—you can add new effects without breaking existing code, unlike transformer stacks where adding a layer forces upstream changes.

The combination of open‑world extensibility and compile‑time inlining keeps both developer ergonomics and runtime performance high.


Migration Path for Existing Codebases

  1. Identify the effectful domain – List the monad transformers currently in use (ReaderT, StateT, ExceptT, etc.).
  2. Define corresponding algebraic effects – For each transformer, create an effect signature. Example: ReaderConfigReader, StateState.
  3. Port pure logic – Functions that only manipulate data can stay unchanged; just replace the monad constraints with Member constraints.
  4. Implement handlers – Use interpret (Polysemy) or runReader/runState equivalents to provide concrete semantics.
  5. Gradual replacement – Keep the old transformer stack for a module while new code uses effects; later replace the old stack with a thin wrapper that re‑interprets the effects.

A real‑world case study from the Polysemy repo shows a 30 % reduction in lines of code and a 12 % speed‑up after migrating a web server from ReaderT (StateT (ExceptT IO)) to an effect‑based design (Polysemy case study).


Key Takeaways

  • Algebraic effects decouple effect signatures from implementations, allowing you to add or remove effects without reshaping a monad‑transformer stack.
  • Handlers are first‑class, providing true lexical scoping and eliminating the need for lift gymnastics.
  • Performance is comparable to hand‑written transformer code thanks to aggressive inlining and fusion techniques.
  • Type inference stays manageable because Member constraints are simple list lookups rather than deep monad transformer hierarchies.
  • Migration is incremental: you can introduce effects alongside existing transformers, gradually refactoring code until the stack disappears.

Further Reading