A function type such as Order -> Receipt says what value goes in and what value comes out. It says nothing about whether the function reads a database, charges a card, consults the clock, mutates memory, launches asynchronous work, or aborts with an error. Those behaviors are often more important to callers than the return value, yet conventional signatures leave them in documentation, naming conventions, and implementation details.
An effect system adds that missing dimension. It describes not only the value transformation but also the capabilities a computation may require or the observable actions it may perform. A signature can distinguish a pure price calculation from an order operation that needs inventory, payment, time, and auditing. Composition then carries those requirements through the call graph, while handlers decide what each capability means in a particular runtime.
The thesis is not that effects make impure programs pure. It is that side effects become explicit, composable obligations rather than ambient surprises. That improves local reasoning, but only if effect labels are meaningful, handlers enforce their promises, and escape hatches remain visible.
Read an Effect Annotation as a Capability Contract
In an illustrative effect notation, a function type has a value result and an effect set:
calculateTotal : Cart -> Money ! {}
loadCart : CartId -> Cart ! {Database, Error<NotFound>}
checkout : CartId -> Receipt ! {Database, Payment, Clock, Audit, Error<CheckoutError>}
The empty set on calculateTotal states that evaluating it requires no declared capability. Given the same arguments, it cannot directly read a clock or call payment merely because those services happen to be reachable. loadCart may use database operations and may raise a typed not-found error. checkout exposes a broader contract before a caller opens the implementation.
There are two related ways to interpret an effect label. A behavioral reading says the computation may perform an operation such as raise, await, or getState. A capability reading says it requires authority supplied by a handler, such as Payment or FileSystem. Many practical systems combine the two. The important property is conservative visibility: an annotation lists what may happen, not what must happen on every execution. A cached branch may avoid Database, but its type can still include that effect.
Effects refine ordinary input and output types rather than replacing them. Money still prevents returning an arbitrary string, while Payment distinguishes computing a charge from executing one. The distinction also differs from a Boolean such as isDryRun. A Boolean selects behavior at runtime; an effect requirement restricts which contexts can run the computation at all.
Effect labels can carry types. Error<CheckoutError> records an expected failure domain, and State<InventoryDraft> identifies the state being manipulated. Some languages infer effect sets, while others require public annotations. In either case, exported signatures should communicate stable intent rather than expose every private implementation detail.
Note
An effect annotation is usually an upper bound. A function typed with {Database, Audit} is permitted to use both effects, but one branch may use only Audit and another may return without performing either operation.
Composition Propagates Requirements Through the Call Graph
Effect composition follows a simple rule: a function must account for the effects of every computation it invokes. If checkout calls loadCart and charge, its effect set includes the requirements that remain unhandled from both calls.
charge : PaymentRequest -> ChargeId ! {Payment, Error<PaymentError>}
save : Receipt -> Unit ! {Database, Error<WriteError>}
checkout : CartId -> Receipt ! {
Database,
Payment,
Error<NotFound | PaymentError | WriteError>
}
Conceptually, sequential composition uses the union of effects:
Conditionals take the union of possible branches. A loop does not multiply an effect in its type; it may perform the same declared operation many times at runtime. Effect information therefore describes kinds of behavior, not frequency, latency, or cost.
Higher-order functions need to preserve the effects of callbacks. A retry helper should not claim purity merely because it does not know what its callback does:
retry : (Unit -> A ! e, RetryPolicy) -> A ! {e, Clock, Random, Error<RetriesExhausted>}
Here e stands for whatever effects the action already has. The helper adds clock and randomness for delayed jitter and adds its own terminal error. This form of effect polymorphism lets one reusable combinator work for a database call, a remote request, or a test action without granting unrelated capabilities.
Subeffecting is another common rule. A pure function can be used where a caller permits {Database} because requiring nothing is safe in a more capable context. The reverse is unsafe: passing a database-reading callback to an API that promises to execute a pure callback would violate that API’s contract.
These rules turn refactoring into useful feedback. If a formerly pure pricing function starts reading exchange rates from a service, the new capability propagates until some boundary handles it. The compiler identifies callers whose assumptions changed. It does not decide whether the change is architecturally wise, but it makes the dependency difficult to hide accidentally.
Work Through a Checkout With Explicit Effects
Consider an order service with four capabilities. The syntax below is language-neutral pseudocode; the point is the contract and control flow, not a particular compiler.
effect Inventory {
reserve : (Sku, Quantity) -> ReservationId
release : ReservationId -> Unit
}
effect Payment {
charge : (AccountId, Money, IdempotencyKey) -> ChargeId
refund : ChargeId -> Unit
}
effect Clock {
now : Unit -> Instant
}
effect Audit {
append : AuditEvent -> Unit
}
The application function names every capability it can request and uses a typed error effect for domain failures:
placeOrder : Command -> Confirmation ! {
Inventory,
Payment,
Clock,
Audit,
Error<OrderError>
}
placeOrder(command) = {
validate(command) // pure
reservation = Inventory.reserve(command.sku, command.quantity)
chargeId = try {
Payment.charge(
command.accountId,
command.total,
command.idempotencyKey
)
} catch paymentError {
Inventory.release(reservation)
Error.raise(PaymentDeclined(paymentError.code))
}
timestamp = Clock.now()
confirmation = Confirmation(command.orderId, reservation, chargeId, timestamp)
Audit.append(OrderPlaced(confirmation))
confirmation
}
The signature reveals more than an ordinary dependency list. validate is pure and can be evaluated without an environment. placeOrder can fail through a declared channel. The compensation path uses Inventory, so removing reservation from the success path would not remove the capability from the overall computation. A caller that lacks Payment cannot run this program merely by reaching the function symbol.
The example also exposes what the type does not prove. It does not make reserve and charge atomic. It does not prove that release always succeeds, that an audit event is durable, or that two calls with the same key cannot double-charge. Those guarantees belong to handler contracts and external protocols. The effect set identifies where authority and failure enter; it does not replace transactional design.
Suppose a quote-only endpoint reuses validation and total calculation but must never charge. It can call the pure pieces and receive a signature such as:
quote : Command -> Quote ! {Inventory, Error<QuoteError>}
That narrower type is reviewable evidence that the endpoint has no payment capability. A runtime permission check is still necessary at the payment boundary, but accidental calls are rejected before deployment.
Handlers Give Effects Their Meaning
An effect declaration describes operations. A handler interprets them. The same application computation can be run with production handlers, an in-memory test model, a transaction-aware interpreter, or a handler that records requests without executing them.
runCheckout(command) =
placeOrder(command)
|> handle Inventory with postgresInventory(pool)
|> handle Payment with paymentGateway(client, credentials)
|> handle Clock with systemClock()
|> handle Audit with durableAudit(log)
|> handle Error<OrderError> with toHttpProblem()
Handling can discharge an effect: after Clock is interpreted with a concrete clock, the outer computation no longer requires the abstract Clock capability. A handler can also transform effects. A payment handler implemented over Http and Secrets may replace Payment with those lower-level requirements. A state handler can run State<S> locally and return both the result and final state. An error handler can convert Error<E> into a value such as Result<A, E>.
Algebraic effect handlers can do more than ordinary method delegation because they receive the suspended continuation around an operation. A handler for Choose might resume a computation once for each candidate. A retry handler might resume after a transient failure. That power demands clear semantics: handler order can change behavior when state, error, cancellation, and retry interact.
For example, placing a state handler inside retry can create fresh state for every attempt; placing it outside can retain mutations from failed attempts. Neither is universally correct. The types may list the same effects while the handler stack produces different operational behavior. Handler order should therefore be part of API design and tests, not incidental plumbing.
Handlers are also security boundaries only when capabilities are unforgeable and scoped. If any module can construct a production Payment handler or invoke the gateway directly through an unchecked foreign-function interface, the annotation becomes documentation rather than enforcement. Keep powerful handlers near composition roots, pass the least authority needed, and audit bypasses.
Relate Effects to Familiar Mechanisms Without Equating Them
Several existing mechanisms solve parts of the same problem:
| Mechanism | What it makes explicit | What commonly remains hidden |
|---|---|---|
| Checked exceptions | Declared error alternatives | State, I/O, time, async work, authority |
async return type |
Suspension and eventual completion | Which external capabilities are used |
| Dependency injection | Runtime implementation dependencies | Whether a particular function actually uses them |
| Capability objects | Authority available through values | Transitive requirements unless signatures carry them |
| Result values | Expected errors in ordinary control flow | Other effects and handler interpretation |
| Effect system | Declared behaviors or capabilities plus composition | Runtime correctness inside handlers |
Checked exceptions are effectively a specialized error effect, but they often lack general handler composition and effect polymorphism. An async marker exposes one control effect while saying nothing about files, networks, state, or cancellation. Dependency injection can provide excellent architecture, yet a service object with ten injected dependencies does not show which method needs which four.
Passing capability values explicitly is a strong alternative in languages without effect syntax. A function can accept a narrow Clock or Payment interface, and tests can supply fakes. The difference is propagation: an effect-aware compiler calculates requirements through higher-order calls, while manual capability threading relies on parameter types and discipline.
Monadic encodings can sequence computations carrying context, state, or errors. They are a library representation; an effect system is a typing discipline that may compile to handlers, explicit values, exceptions, or another mechanism. Neither label implies better runtime behavior. The practical question is whether the chosen representation keeps requirements visible without making ordinary composition unusable.
Balance Precision, Inference, and Adoption Cost
Effect granularity is a design choice. A single IO label is easy to infer and stable under refactoring, but it cannot distinguish a read-only report from a command that sends email. Labels such as DatabaseRead, DatabaseWrite, Email, and Payment communicate authority better, yet exposing every table or endpoint can make signatures brittle.
A useful boundary is domain capability rather than implementation detail. Inventory can remain stable if storage moves from SQL to an HTTP service. Low-level effects still matter inside the handler, but most application code should depend on the operation it needs, not the transport currently implementing it.
Inference reduces annotation noise but can produce surprising public contracts when a private call changes. Explicit exported annotations act as an architectural gate: an implementation that adds a new effect fails until the author deliberately widens the API or handles the effect internally. Local functions can usually rely on inference.
Adoption is easiest at trustworthy boundaries. Start with errors, state, clock, randomness, and a few privileged domain capabilities. Integrate foreign libraries behind handlers. Requiring every legacy call to carry a perfect effect immediately can lead to one giant Unsafe label that teaches nothing.
There are costs. Compiler diagnostics become more complex, especially with polymorphic callbacks and nested handlers. Libraries need compatible annotations. Developers must learn when effects are handled versus propagated. Debuggers and stack traces may need runtime support for resumable handlers. These costs are justified when hidden capabilities cause real review, testing, or security problems; a small application with straightforward explicit parameters may be clearer without a dedicated effect system.
Recognize Failure Modes and Semantic Limits
The most dangerous failure is effect laundering: an unsafe primitive performs I/O while claiming an empty effect set. Foreign-function interfaces, reflection, global mutable state, and unchecked casts are common escape routes. Isolate them in small modules, review them as trusted code, and expose honest effectful wrappers.
An umbrella effect is a softer failure. If every function becomes {App}, signatures compile but cease to distinguish authority. Conversely, effects that mirror every internal method create annotation churn without helping callers. Labels should represent decisions a caller or operator cares about.
Handlers can satisfy the type while violating the behavioral contract. A test payment handler that ignores idempotency is not a faithful model for retry tests. A clock that moves backward can expose valuable edge cases, but production code may require monotonic durations separately from wall time. Specify algebraic laws and operational guarantees for handlers just as for ordinary interfaces.
Cancellation deserves special care. A handler that resumes a continuation after its scope has closed can leak resources or perform actions after a caller has given up. Bracketed acquisition, structured lifetimes, and cancellation-safe finalizers remain necessary. An Async or Cancel effect makes the issue visible; it does not automatically solve cleanup.
Finally, absence from an effect set proves only what the language and trusted runtime define. It does not prove constant time, determinism under data races, termination, or freedom from allocation. Keep claims proportional to the model.
Verify Effects at Compile Time and Handlers at Runtime
Testing should cover both layers. Compile-time tests assert that forbidden compositions do not type-check: a quote endpoint cannot call Payment, a pure callback cannot read Clock, and a handler removes only the effects it actually interprets. Positive tests ensure polymorphic helpers preserve callback effects rather than replacing them with an overly broad label.
Runtime tests exercise handler semantics. For placeOrder, use deterministic handlers that record operations and inject failures at each boundary. Verify at least these traces:
- Successful checkout reserves, charges once, reads time, and appends the expected audit event.
- A declined charge releases the exact reservation and returns a domain error.
- A repeated idempotency key returns the prior charge rather than charging again.
- Failure during compensation is surfaced and queued for reconciliation rather than erased.
- Cancellation closes acquired resources and performs no operation after scope exit.
Property tests can compare handlers against laws: state get followed by put should expose the new value, an in-memory inventory handler should never produce negative available stock, and an error handler should not resume an aborted computation accidentally. Integration tests then verify real handlers against sandbox services and persistence constraints.
In operations, attach telemetry at handler boundaries. Record capability name, outcome, latency, and a trace identifier while excluding secrets and sensitive payloads. This aligns observability with the effect vocabulary: a spike in Payment failures can be separated from application validation errors. Monitor handler versions because changing an interpreter can alter behavior without changing application code.
Effect systems provide their strongest guarantee before execution: they show which powers a computation may request and force those powers to be handled somewhere. Runtime verification completes the story by proving that each handler uses its authority correctly. Together they create a disciplined boundary between a program’s declared intentions and the world that interprets them.