PureScript's Effect System: What Replaced Eff and Why It Still Matters
PureScript's Eff monad and effect rows were removed in 0.12. Here's how the current Effect and Aff model works, with a working example and honest trade-offs.
30 Oct 2025, 19:05 UTC

The problem: side effects you can't see
If you write front-end code in PureScript, most of what your program actually does — logging, HTTP requests, DOM updates, timers — is a side effect. Pure functions are easy to test and refactor; effectful ones are not. PureScript's answer has always been to make effects explicit in types, but the mechanism changed significantly in 2018, and a lot of older material on the web still describes the old one. If you are evaluating PureScript today, you need to know which system you are actually getting.
Eff is gone; Effect is the current model
Up to PureScript 0.11, the language shipped with the Eff monad, which tracked each effect in a type-level row, e.g. Eff (console :: CONSOLE, ajax :: AJAX | e) Unit. The compiler could then tell you exactly which effects a function might perform. It was elegant, but in practice the rows produced verbose signatures and confusing error messages, and interleaving effects with asynchronous code was awkward.
Since PureScript 0.12 (released in 2018), Eff and effect rows were removed. The current model is simpler:
Effect a(from theeffectpackage) is a monad for synchronous, native side effects. AnyEffectmay do any kind of effectful work; the type no longer enumerates which ones.Aff a(from theaffpackage) handles asynchronous effects — HTTP calls, timers, anything callback-shaped in JavaScript — with support for cancellation and forking fibers.- Effect classes such as
MonadEffectandMonadAfflet you write functions that work in any monad that can liftEffectorAff, which restores much of the reuse that row polymorphism provided.
The trade-off is deliberate: you lose per-effect granularity in the type, and you gain signatures that humans can read and an async story that works. The compiler still guarantees that a function typed as pure (no Effect or Aff in its return type) cannot perform effects at all — that core promise is unchanged.
A worked example with current tooling
The following sketch targets the post-0.12 ecosystem (compiler 0.15.x, Spago-based builds). Package names and module paths are worth double-checking against your installed package set, since libraries evolve.
Scaffold a project and add dependencies (run in a terminal; no special permissions needed):
spago init
spago install console aff affjax
A small program that logs and then performs an HTTP GET. Because the request is asynchronous, it lives in Aff; log is lifted from Effect via liftEffect:
module Main where
import Prelude
import Data.Either (Either(..))
import Effect (Effect)
import Effect.Class.Console (log)
import Effect.Aff (launchAff_)
import Effect.Class (liftEffect)
import Affjax.Web as AX
import Affjax.ResponseFormat as ResponseFormat
main :: Effect Unit
main = launchAff_ do
liftEffect $ log "Fetching..."
result <- AX.get ResponseFormat.string "https://example.com/data"
case result of
Left err -> liftEffect $ log ("Request failed: " <> AX.printError err)
Right response -> liftEffect $ log response.body
Build with spago build, then run with spago run (Node) or bundle for the browser with spago bundle-app. A successful build confirms the types line up; it does not confirm the endpoint responds — that you verify by actually running it and checking the console output.
Note what the types tell you: main :: Effect Unit advertises "this does side effects" but not which ones. If you want finer granularity today, the community pattern is to define your own capability records or type classes (e.g. a MonadLog class) and pass implementations explicitly — you get testability back at the cost of some boilerplate, and only where you choose to pay it.
Interoperating with JavaScript safely
Foreign imports are where the effect discipline is easiest to break. The rule of thumb: any foreign function that touches the outside world should be typed as returning Effect (or wrapped in Aff if asynchronous), never as a pure value. For example, in a .js foreign module:
// Foreign.js
export const readTitle = () => document.title;
-- Foreign.purs
foreign import readTitle :: Effect String
The old Eff-era escape hatch unsafePerformEffect still exists in spirit as Effect.Unsafe.unsafePerformEffect. It converts Effect a to a plain a, discarding the guarantee. Treat it as a boundary-only tool — for instance, when a third-party JS API demands a synchronous callback — and document every use.
Trade-offs to weigh before adopting
- Less compile-time granularity than Eff. Two functions typed
Effect Unitmay do wildly different things. If per-effect tracking is a hard requirement, PureScript's current core will not give it to you out of the box. - Two effect monads to learn. Knowing when code belongs in
EffectversusAff, and how to lift between them, is a real onboarding cost. - Stale documentation. Because
Effwas prominent for years, many tutorials and Stack Overflow answers describe APIs that no longer compile. Always check that an example targets compiler 0.12 or later — ideally 0.15.x.
Where to start
- Install the current compiler and Spago, then
spago inita scratch project. - Write one pure function and one
Effectfunction; confirm the compiler rejects calling an effectful binding inside the pure one withoutdonotation in anEffectblock. - Port one real asynchronous call to
Affusing the pattern above, and verify behavior by running it, not just by compiling. - When consulting older material, translate mentally:
Eff (row) ausually becomesEffect a, and anything callback-based becomesAff a.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.