Controlling Effect Rows in Purescript Public APIs
Effect row inference in Purescript can silently generalize side‑effects. This post shows how explicit annotations restore control, with a concrete example and compile‑time verification.
06 Sept 2026, 15:27 UTC

You’re probably over‑effecting your functions
In Purescript, every function that touches the outside world carries an effect row — a type‑level list of the side‑effects it may perform. When you call console.log or read a file, the compiler infers a row and generalizes it. If you don’t annotate, the row can grow or shrink in ways that feel surprising, especially in public APIs where callers depend on what effects are allowed.
Consider a function that logs a startup message:
module Example where
import Control.Monad.Eff (Eff)
import Control.Monad.Eff.Console (console)
-- No row annotation:
logStartup :: forall e. Eff e Unit
logStartup = do
console.info 'Starting up'
The type forall e. Eff e Unit tells the compiler this function runs in some effect row e, but it doesn’t specify what e contains. A caller can supply any effect row, and the function will type‑check even if you later add a random or network effect inside the function body.
How Purescript infers effect rows
Purescript’s effect system is built on the Eff monad, which sequences effectful computations through pure, bind, and runEff. An effect row tracks which capabilities a computation requires. The compiler can infer these rows automatically, and it will generalize them when a function is polymorphic over e. This is convenient for library code, but it means the caller ends up responsible for whatever effects the function might use — even if you never intended them.
You can read the inferred row for any function using the Purs IDE or running purs docs and hovering over the type. The row annotation syntax uses a constraint like Console <= e, read as the effect row e must include Console.
Explicit annotations in practice
Adding a row constraint narrows the function’s capabilities and makes the API more predictable.
module Example where
import Control.Monad.Eff (Eff)
import Control.Monad.Eff.Console (console)
-- Explicit row constraint:
logStartup :: forall e. Console <= e => Eff e Unit
logStartup = do
console.info 'Starting up'
Now the annotation means: logStartup runs in any effect row e that includes Console. If you later add a random effect call inside the function, the compiler will reject the code unless you also expand the constraint. This early feedback is exactly what you want in a maintained codebase.
You can also inspect the change: run the compiler and check the generated JavaScript, or use purs docs to confirm the type now reads foralls e. Console <= e => Eff e Unit.
Trade‑offs, verification, and maintenance
- Verbosity. Every public function that uses effects may need a constraint, increasing type‑signature surface area.
- Row ambiguity. Mixing row variables with concrete effects can still infer a more general row than expected. Always double‑check with
purs docsafter adding an annotation. - Foreign imports. A
foreign importcan perform any effect, bypassing the row system. If your module calls foreign code, annotate the surrounding function carefully so callers understand the widened capability.
In practice, the safety gain outweighs the extra characters for functions that form part of a published API. For internal glue code, you may choose to omit annotations and rely on whole‑program inference.
Start by annotating one frequently‑used function’s effect row today. Use purs docs to verify the inferred type, and watch how the compiler guides you when you later add or remove side‑effects. Over time, explicit row constraints become a habit that makes Purescript’s type system work for you, not against you.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.