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.
- Lifting –
lift(orliftIO) 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
Monadinstance changes.
A real‑world codebase can quickly reach a point where the stack is a tangled mess of ReaderT → StateT → ExceptT → WriterT → IO. 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
MonadMasktricks or custom transformer layers. - Higher‑order effects – Effects that themselves manipulate other effects (e.g., a
Coroutinethat 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
- OCaml – Effect handlers are part of the language (see the OCaml manual on effects). A simple example:
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
Memberconstraint 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
- Identify the effectful domain – List the monad transformers currently in use (
ReaderT,StateT,ExceptT, etc.). - Define corresponding algebraic effects – For each transformer, create an effect signature. Example:
Reader→ConfigReader,State→State. - Port pure logic – Functions that only manipulate data can stay unchanged; just replace the monad constraints with
Memberconstraints. - Implement handlers – Use
interpret(Polysemy) orrunReader/runStateequivalents to provide concrete semantics. - 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
liftgymnastics. - Performance is comparable to hand‑written transformer code thanks to aggressive inlining and fusion techniques.
- Type inference stays manageable because
Memberconstraints 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
- Algebraic Effects and Handlers – a survey by Plotkin and Pretnar – foundational paper introducing the concept.
- Polysemy: A Library for Handling Algebraic Effects in Haskell – official repository with documentation and benchmarks.
- OCaml Manual – Effects – language‑level support for algebraic effects.
- Koka – a language with effect handlers built in – showcases practical use‑cases of scoped effects.
- Eff: Extensible Effects for Haskell – another Haskell library that predates Polysemy.
- “Algebraic Effects for Beginners” by Simon Peyton Jones (video) – accessible introduction to the theory and practice.
