Skip to content

polysemy (Haskell) ​

A higher-order, low-boilerplate extensible effects library based on freer monads. polysemy pioneered accessible higher-order effects in Haskell via its Tactics API and remains influential despite performance limitations.

FieldValue
LanguageHaskell
LicenseBSD-3-Clause
Repositorygithub.com/polysemy-research/polysemy
DocumentationHackage
Key AuthorsSandy Maguire
EncodingFreer monad with type-level effect rows

Overview ​

What It Solves ​

polysemy provides extensible effects with minimal boilerplate and strong support for higher-order effects. It avoids the O(n^2) instances problem of mtl, composes better than monad transformers, is more powerful than freer-simple (which lacks higher-order effects), and requires an order of magnitude less boilerplate than fused-effects.

Design Philosophy ​

polysemy prioritizes ergonomics and expressiveness over raw performance. Effects are defined as simple GADTs, and interpretation requires minimal ceremony. The Tactics API enables higher-order effects -- operations that take monadic computations as arguments -- which was a significant advance over freer-simple.


Core Abstractions and Types ​

The Sem Monad ​

haskell
newtype Sem (r :: EffectRow) a

Sem r describes a program with the capabilities listed in r. The r parameter should generally be kept polymorphic, with capabilities added via Member constraints. The Sem monad allows writing programs against a set of effects without predefined meaning, with that meaning provided later by interpretation.

Effect Kinds ​

haskell
type Effect    = (Type -> Type) -> Type -> Type
type EffectRow = [Effect]

Effects have kind (* -> *) -> * -> *. The extra (* -> *) parameter holds a monad, enabling higher-order effects. For example, Error e has constructors like Throw and Catch where the m parameter allows monadic arguments.

The Union Type ​

haskell
data Union (r :: EffectRow) (mWoven :: Type -> Type) a

Union is an extensible, type-safe union -- the core internal type that stores effect values together with information about the higher-order interpretation state. It is indexed by the effect row r, and any one of the effects in r may be held within it.

Member Constraint ​

haskell
type Member e r  -- e is a member of effect row r

-- Example:
greet :: Member (Output String) r => Sem r ()
greet = output "hello"

Member (State s) r is analogous to mtl's MonadState s m. Unlike mtl, a Sem may have an arbitrary number of the same effect -- for example, you can have both Member (Output Int) r and Member (Output Bool) r simultaneously, disambiguated at the type level.


How Effects Are Declared ​

Effects are defined as GADTs with one constructor per operation:

haskell
data Teletype m a where
  ReadTTY  :: Teletype m String
  WriteTTY :: String -> Teletype m ()

makeSem ''Teletype  -- Template Haskell generates smart constructors

The makeSem TH splice generates:

haskell
readTTY  :: Member Teletype r => Sem r String
writeTTY :: Member Teletype r => String -> Sem r ()

Higher-Order Effects ​

haskell
data Error e m a where
  Throw :: e -> Error e m a
  Catch :: m a -> (e -> m a) -> Error e m a  -- 'm' appears in arguments

The presence of m in constructor arguments makes Catch a higher-order operation.


How Handlers/Interpreters Work ​

Basic Interpretation ​

haskell
interpret
  :: FirstOrder e "interpret"
  => (forall rInitial x. e (Sem rInitial) x -> Sem r x)
  -> Sem (e ': r) a
  -> Sem r a

For first-order effects (no m in constructors), interpretation is straightforward -- each constructor maps to a Sem computation.

interpretH (Higher-Order) ​

haskell
interpretH
  :: (forall rInitial x. e (Sem rInitial) x -> Tactical e (Sem rInitial) r x)
  -> Sem (e ': r) a
  -> Sem r a

For higher-order effects, interpretH provides access to the Tactics API.

The Tactics API ​

The Tactics API is polysemy's mechanism for handling higher-order effects. It provides:

  • runT: Run a monadic action from a higher-order effect constructor, threading the interpreter's state
  • bindT: Bind the result of runT to continue interpretation
  • getInitialStateT: Get the initial state of the interpretation context
  • pureT: Lift a pure value into the tactical context

Example -- interpreting Catch:

haskell
runError :: Sem (Error e ': r) a -> Sem r (Either e a)
runError = interpretH $ \case
  Throw e -> pure (Left e)
  Catch action handler -> do
    result <- runT action
    case result of
      Left e  -> bindT (handler e)
      Right a -> pure (Right a)

Weaving (Internal) ​

Internally, higher-order interpretation works via weaving -- threading a functor of state through effect constructors. The Weaving type accumulates weaving functions of the form:

haskell
Functor f => f () -> (forall x. f (m x) -> n (f x)) -> e m a -> e n (f a)

The functor f corresponds to some piece of state, and the distribution function is analogous to runStateT.

Running the Final Result ​

haskell
run      :: Sem '[] a -> a                  -- pure result
runM     :: Monad m => Sem '[Embed m] a -> m a  -- into any monad
runFinal :: Monad m => Sem '[Final m] a -> m a  -- higher-order embedding

Performance Approach ​

The Performance Problem ​

polysemy is based on freer monads, which build an explicit syntax tree of effects. This has inherent overhead:

  1. Tree construction: Each send allocates a node in the effect tree
  2. Tree traversal: Interpretation walks the tree, pattern-matching on each node
  3. GHC optimization brittleness: Performance depends heavily on GHC's ability to inline and specialize, which is unpredictable for larger programs

Sandy Maguire documented these issues in his post "Polysemy: Mea Culpa", acknowledging that the hoped-for GHC optimizations did not materialize for real programs.

Benchmark Position ​

  • Roughly 1-3 orders of magnitude slower than fused-effects and mtl
  • Similar performance to freer-simple but with higher initial overhead
  • The performance gap widens in "deep" benchmarks with many effects in scope

Proposed Solutions ​

Alexis King proposed GHC primops for delimited continuations that would allow effect interpretation at runtime with minimal overhead. While not zero-cost, the overhead would be negligible in practice. This work led to GHC Proposal #313 and the eff library.


Composability Model ​

Effect Ordering ​

The order of interpreters determines behavior:

haskell
-- State rolled back on error:
runError . runState @Int 0 $ program

-- State preserved on error:
runState @Int 0 . runError $ program

Members Constraint ​

haskell
type Members es r = ...  -- all effects in es are members of r

program :: Members '[State Int, Error String, Output Log] r => Sem r ()

Embedding External Monads ​

haskell
embed :: Member (Embed m) r => m a -> Sem r a      -- lift monadic actions
embedFinal :: Member (Final m) r => m a -> Sem r a  -- higher-order embedding

Strengths ​

  • Minimal boilerplate: Effect definition via GADTs + makeSem; interpretation via pattern matching
  • Higher-order effects: The Tactics API was groundbreaking for accessible scoped effects
  • No O(n^2) instances: Unlike mtl, no quadratic growth of typeclass instances
  • Custom type errors: Helpful error messages when effects are missing or ambiguous
  • Multiple same-type effects: Naturally supported
  • Pure interpretation: Can interpret to pure values without IO
  • Good documentation: Extensive tutorials and blog posts by Sandy Maguire

Weaknesses ​

  • Poor performance: 1-3 orders of magnitude slower than mtl/effectful; depends on brittle GHC optimizations
  • Unsound higher-order semantics: Some combinations of higher-order effects can produce incorrect results (documented by heftia authors)
  • Template Haskell dependency: makeSem requires TH, complicating cross-compilation
  • Complex internals: Weaving, Tactics, and Union are difficult to understand and extend
  • Abandoned by author: Sandy Maguire moved on after documenting performance issues; community maintenance

Key Design Decisions and Trade-offs ​

DecisionRationaleTrade-off
Freer monad encodingMinimal boilerplate; effects as dataInherent performance overhead from tree construction
Tactics APIAccessible higher-order effectsComplex internals; some unsound semantics
Type-level effect rowsCompile-time safety; multiple same-type effectsType errors can be cryptic; type inference sometimes struggles
Template Haskell for smart constructorsEliminates boilerplateCross-compilation issues; opaque generated code
Pure interpretationNo IO dependency; testabilityPerformance cost; cannot use IO-based optimizations

Sources ​