Effect checking for Gleam.
graded verifies that your Gleam functions respect their declared effect budgets. The tool reads and writes a single spec file at the root of your package — your Gleam source stays untouched.
gleam add --dev gradedInfer effects for your project:
gleam run -m graded inferThis scans src/, analyses every function, and writes two outputs:
<package_name>.gradedat the project root — the spec file. Contains the inferred effects of every public function plus any hand-writtencheckinvariants,external effectshints, andtypefield annotations. Tracked in git, ships to consumers if you add it toincluded_filesingleam.toml.build/.graded/<module>.graded— per-module cache files. Contain the inferred effects of every function (public and private). Regenerated freely on eachgraded inferrun, never shipped (build/is gitignored).
In a Lustre app, view must be pure — it builds HTML from the model without side effects. Enforce this with graded:
// src/app.gleam
import gleam/io
import lustre/element.{type Element}
import lustre/element/html
pub fn view(model: Model) -> Element(Msg) {
io.println("rendering") // oops — side effect in view!
html.div([], [html.text(model.name)])
}// app.graded — at the project root
check app.view : []
$ gleam run -m graded check
src/app.gleam: view calls gleam/io.println with effects [Stdout] but declared []
graded: 1 violation(s) foundRemove the io.println and the check passes. Lustre's init and update functions are also pure — they return #(Model, Effect(Msg)) where Effect is a data description, not an executed side effect.
Function names in the spec file are module-qualified: app.view means the view function in module app. Use slashes for nested module paths (app/router.handle_request).
Constrain any function's effect budget:
// app.graded — at the project root
check app/router.handle_request : [Http, Stdout]
If handle_request does something outside its budget (like writing to a database), graded reports the violation with the call site.
myapp/
├── src/
│ ├── myapp.gleam
│ └── myapp/
│ └── router.gleam
├── myapp.graded ← spec file (tracked, shipped, hand-editable)
├── build/
│ └── .graded/ ← cache (gitignored, not shipped)
│ ├── myapp.graded
│ └── myapp/
│ └── router.graded
├── gleam.toml
└── ...
graded reads its configuration from a [tools.graded] table in gleam.toml. Both fields are optional — omit them to get the defaults.
[tools.graded]
spec_file = "myapp.graded" # default: "<package_name>.graded"
cache_dir = "build/.graded" # default: "build/.graded"If you're a library author and want downstream packages to read your effect annotations, add the spec file to included_files in your gleam.toml:
included_files = [
"src",
"myapp.graded", # ← add this so consumers see your effects
"gleam.toml",
"README.md",
]The cache directory under build/ is gitignored and never ships, regardless of included_files.
Four annotation kinds, all in the spec file:
effects mod.fn : [...]— inferred public-API effects, regenerated bygraded infer. Replaced on each run; do not edit by hand.check mod.fn : [...]— invariant, enforced bygraded check. Violations break the build.type mod.Type.field : [...]— declares effects for function-typed fields on custom types.external effects mod.fn : [...]— declares effects for external / third-party functions.
The checker walks the Gleam AST (via glance), resolves imports, follows local calls transitively, and unions the effect sets. If the actual effects aren't a subset of the declared budget, it's a violation.
Effect knowledge is resolved in priority order:
- Your spec file —
check,external effects, andtypefield declarations you wrote in<package_name>.graded - Cross-module project effects — inferred effects from sibling modules in the same project, propagated in topological order
- Dependency spec files — shipped by libraries at
build/packages/<dep>/<dep_spec_file>(each dep's spec file path is read from its own[tools.graded]config) - Path dependencies — local deps declared with
path = "..."ingleam.toml. graded reads their spec files; if missing, it falls back to inferring from source. - Bundled catalog — versioned catalog files shipped with graded (see below)
- Conservative default — unknown functions get
[Unknown]
Functions that accept callbacks can declare parameter effect bounds:
// f must be pure — safe_map inherits no effects from its callback
check myapp.safe_map(f: []) : []
// apply passes through f's effects
effects myapp.apply(f: [Stdout]) : [Stdout]
When checking, calls to bounded parameters (like f(x) inside apply) use the declared bound instead of [Unknown].
For functions whose effects depend on their callback, use lowercase effect variables:
// validate_range's effects are whatever to_error's effects are
effects myapp.validate_range(to_error: [e]) : [e]
// map_with_log carries [Stdout] on top of f's effects
effects myapp.map_with_log(f: [e]) : [Stdout, e]
graded infer produces these automatically when it sees a function calling a parameter with a fn(...) -> ... type annotation — the variable is named after the parameter. At each call site, graded binds the variable to the concrete effects of the argument passed:
- A function reference (
io.println) → its effects from the knowledge base - A type constructor (
OutOfRange) → pure[] - The caller's own bounded parameter → that bound's effects
- Anything else (inline closure, computed expression) →
[Unknown]
Both labeled (validate_range(42, to_error: OutOfRange)) and positional (validate_range(42, OutOfRange)) arguments resolve.
For types with function-typed fields, declare their effects at the type level. Type names use the same module-qualified form as function names — module path with slashes, then .TypeName.field:
type myapp.Handler.on_click : [Dom]
type myapp/router.Request.send : [Http]
When checking handler.on_click(event), graded looks up the parameter's type annotation to resolve the field's effects. Parameters must be explicitly typed in the Gleam source for this to work.
Annotate third-party library functions without modifying the library:
external effects gleam/httpc.send : [Http]
external effects simplifile.read : [FileSystem]
external effects gleam/otp/actor.start : [Process]
Externals are merged into the knowledge base before both infer and check, so calls to these functions resolve with the declared effects instead of [Unknown]. This is also the right mechanism for FFI functions — declare their effects so callers propagate correctly.
Four shapes appear inside brackets:
[]— pure; no effects. The bottom of the effect lattice.[Label1, Label2, ...]— a specific set of effect labels.[_]— wildcard; the top of the effect lattice. When used as a declared budget,[_]means "any effects are permitted here" and matches anything. Useful for entrypoints (main) or for deliberately un-restricted parameter bounds (check run(f: [_]) : [_]).[e],[e1, e2](lowercase-initial tokens) — effect variables for polymorphic signatures. See Effect polymorphism.
Note on wildcards: because [_] is lattice top, it absorbs everything in unions. If a function's inferred effects would be [Stdout, e] (polymorphic) but its declared type is [_], the variable info is subsumed. That's correct but can be surprising — if you want polymorphism, avoid declaring wildcard bounds.
Effect labels are plain strings — you can use any name. The bundled catalog uses these conventions:
| Label | Meaning | Example functions |
|---|---|---|
Stdout |
Writes to standard output | gleam/io.println, gleam/io.debug |
Stderr |
Writes to standard error | gleam/io.print_error |
Stdin |
Reads from standard input | gleam/erlang.get_line |
Process |
Spawns, sends to, or manages BEAM processes | gleam/erlang/process.send, gleam/otp/actor.start |
Http |
Network HTTP requests | gleam/httpc.send, lustre_http.get |
FileSystem |
Reads or writes the filesystem | simplifile.read, simplifile.write |
Dom |
Browser DOM manipulation | lustre.start, lustre.register |
Time |
Reads system clock or timezone | gleam/time/timestamp.system_time, gleam/time/calendar.local_offset |
You can define your own labels for project-specific effects:
external effects my_app/email.send : [Email]
external effects my_app/metrics.record : [Telemetry]
check my_app/api.handle_request : [Http, Email]
graded inferregenerates the inferredeffectslines in the spec file while preservingcheck,type,external, comments, and blank lines.graded formatnormalizes spacing and sorting in the spec file.
graded ships with versioned catalog files for common Gleam packages, so you get effect knowledge out of the box without writing external effects declarations for standard libraries.
Catalog files live in priv/catalog/ and are named {package}@{version}.graded. At load time, graded reads your project's manifest.toml to determine installed dependency versions, then selects the highest catalog version that doesn't exceed the installed version.
For example, if you have gleam_stdlib@0.71.0 installed and the catalog has gleam_stdlib@0.70.0.graded, that file is used — effects don't change between patch versions. A new catalog file is only needed when a library adds modules or changes effect semantics.
| Package | Effects | Labels |
|---|---|---|
| gleam_stdlib | gleam/io.* |
Stdout, Stderr |
| gleam_erlang | gleam/erlang/process.* |
Process, Stdin, FileSystem |
| gleam_otp | gleam/otp/actor.*, gleam/otp/supervisor.* |
Process |
| gleam_httpc | gleam/httpc.send |
Http |
| lustre | lustre.start, lustre.send, lustre/server_component.* |
Process, Dom |
| lustre_http | lustre_http.* |
Http |
| simplifile | simplifile.* |
FileSystem |
| gleam_time | gleam/time/timestamp.system_time, gleam/time/calendar.local_offset, .utc_offset |
Time |
Pure (all functions []): gleam_http, gleam_json, filepath, gleam_regexp, gleam_yielder, gleam_crypto, houdini, tom.
For packages not in the catalog, use external effects declarations in your project's spec file.
gleam run -m graded check [directory] # enforce check annotations (default)
gleam run -m graded infer [directory] # infer and write effects annotations
gleam run -m graded format [directory] # normalize .graded file formatting
gleam run -m graded format --check [directory] # verify formatting (CI mode)
gleam run -m graded format --stdin # format from stdin (editor integration)graded combines syntax-level analysis using glance with type information from girard, a Hindley-Milner type annotator for Gleam that graded runs over the whole package. Types are an enhancement layer applied per function: a function girard can't type falls back to the syntax-level path, so types only ever sharpen a result, never change a resolved one. A few patterns remain unresolved:
-
Field calls resolve through real types, with limits on the inferred effect. A field call
v.to_error(42)resolves regardless of howvwas obtained — a function parameter, a value returned from another function, an alias chain — because girard suppliesv's nominal type. The field's effect comes from a hand-writtentype myapp.Validator.to_error : [...]line, or is inferred automatically from the constructor call site (Validator(to_error: io.println)⟹to_error : [Stdout], unioned across all sites in the package). If the wired function is effect-polymorphic, its variables are bound to the field call's own arguments, the same way resolved calls are. A field wired to an inline closure (Validator(to_error: fn(m) { io.println(m) })) is resolved by analysing the closure body — including an operator-typed field whose closure calls its own callback (Middleware(wrap: fn(next) { next() })is lifted toλnext. [next]and applied at the field call). The inferred effect falls back to[Unknown]only when a field is wired to a value graded can't statically resolve — a constructor parameter, or a local that isn't a traceable function. For those, add thetypeannotation. Named function references resolve whether same-module or cross-module, and cross-module constructors called with positional arguments are routed to their fields via the package-wide constructor-label map. Field effects are keyed by the type's defining module (from girard's inferred type), so two different types namedValidatorin different modules stay distinct. -
Second-order (nested) effect variables are supported, with one narrow residual. Effects are represented as a small lambda-calculus-with-union (
EffectTerm);EffectSetis its ground normal form. A higher-kinded effect variable — an operatorEff → Effrather than a flatEff— arises when a parameter's own type takes one or more functions (action: fn(fn() -> Nil) -> a, orfn(fn() -> _, fn() -> _) -> _). A call to such an operator parameter,action(cb1, cb2), infers a curried effect-operator application[action([Stdout], [FileSystem])]over every callback in order (none dropped), and at a call site an operator-typed argument is lifted to an operator and the application beta-reduces to the concrete effect. Transitive first-order propagation (outer(f) → middle(f) → inner(f)⟹[f],apply2(f, g) : [f, g]) still works as before. See docs/second-order-effects.md for the design and the property suite. Operator arguments are lifted from a named function reference (cross-module via the knowledge base, same-module via on-demand transitive analysis), an inline closure, a let-bound closure (let h = fn(cb) { … }), acase/ifbranch over function-like options (case c { True -> f False -> g }, joined as(f ⊔ g)(cb) = f(cb) ⊔ g(cb)), a block evaluating to any of these ({ let f = …; f }), and a function returned from a call (let h = pick_handler()). A producer's returned operator is inferred where it's defined and serialized into the spec (returns mod.fn : fn(cb) -> [cb]), so it resolves across module and package boundaries duringcheck, not justinfer. A returned operator may be polymorphic in the producer's parameters — a producer that returns one of its operator parameters (fn wrap(base) { base }) or wraps it in a closure (a decorator,fn traced(action) { fn(cb) { log(); action(cb) } }) resolves, with the parameter bound to the producer call's argument. (A returned closure is lazy, so it's excluded from the producer's own direct call-effect — counted only when applied — which keeps the decorator's result precise.) Residual: still[Unknown]are: a producer that selects a parameter through a branch (case … { _ -> a _ -> b }); a field wired to a constructor parameter (inter-procedural value flow); a function value reached through arbitrary computation (handlers |> list.first); anduse-tailed returns. Annotate explicitly or widen the budget if needed. -
Unusual pipe target shapes aren't tracked.
x |> foo,x |> foo.bar,x |> foo(args), andx |> foo.bar(args)all work, including with positional argument substitution for polymorphic callees. Less common shapes likex |> { let f = bar(); f }don't have a static callee name for the extractor to hang argument tracking off. -
Cross-module resolution is automatic. If module A calls module B,
checkinfers any project module missing from the spec in memory (topological order over the import graph), so a fresh checkout resolves transitive chains without a priorgraded infer. Committedeffectslines in the spec take priority — they're never overridden — andcheckwrites nothing to disk; rungraded inferto persist the cache and spec. -
External code is opaque. Erlang/JavaScript FFI implementations, pre-compiled dependencies without
.gradedfiles, and dynamically dispatched calls cannot be analyzed. Useexternal effectsdeclarations to annotate these manually.
In practice, idiomatic Gleam code (inline callbacks, direct calls, pipe chains, higher-order functions passing functions by name, validator/handler/config records) is handled correctly. Function references passed to higher-order functions are tracked via auto-inferred polymorphic signatures (effects map(f: [e]) : [e]) and bound at each call site; locally-bound function-ref aliases (let f = io.println; f(x)), transitive aliases (let g = f), and field calls on records — whether constructed in the same function or returned from another — resolve through girard's inferred receiver types.
The following features would progressively close the remaining limitations. Ordered by incrementality — earlier items are smaller, later items push into different territory.
Extend parameter bounds to accept a path expression, so users can declare a field's effects at the function boundary when graded can't figure it out on its own:
check myapp.view(handler.on_click: [Dom]) : [Dom]
This is a syntax extension to ParamBound (path instead of identifier), no analysis required — the user declares what a record field's effects are, and substitution works exactly like first-order param bounds. Covers the escape-hatch case for field calls and for any other value flow graded can't trace.
graded reads expression types from girard, which already resolves field calls without explicit parameter annotations and detects fn-typed parameters. The one piece not taken: replacing the positional/label argument-matching heuristics (find_matching_arg/position_from_registry). They drive polymorphic call-site substitution — a subsystem girard's expression types don't cleanly map onto — so they were kept deliberately. Revisit only if a concrete imprecision surfaces.
The next major feature is lattice-based privacy tracking — preventing sensitive data (PII, credentials) from flowing into logs, error messages, or third-party services.
Both checkers share the same theoretical foundation: graded modal type theory (see THEORY.md). Effects use sets with union; privacy uses lattices with join.
Apache-2.0