Effect checking for Gleam via sidecar annotations.
graded verifies that your Gleam functions respect their declared effect budgets. Annotations live in .graded sidecar files alongside your source — your Gleam code stays clean.
gleam add --dev gradedInfer effects for your project:
gleam run -m graded inferThis scans src/, analyzes every public function, and writes effect annotations to priv/graded/.
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)])
}// priv/graded/app.graded
check 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.
Constrain any function's effect budget:
// priv/graded/api.graded
check 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.
Four annotation types in .graded files:
effects fn : [...]— inferred cache, auto-generated bygraded infer, not enforcedcheck fn : [...]— invariant, enforced bygraded check, violations break the buildtype Type.field : [...]— declares effects for function-typed fields on custom typesexternal effects module.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
.gradedfiles —external effectsdeclarations you write for your project - Dependency
.gradedfiles — shipped by libraries inbuild/packages/*/priv/graded/ - 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 safe_map(f: []) : []
// apply passes through f's effects
effects apply(f: [Stdout]) : [Stdout]
When checking, calls to bounded parameters (like f(x) inside apply) use the declared bound instead of [Unknown].
For types with function-typed fields, declare their effects at the type level:
type Handler.on_click : [Dom]
type 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. If your project has Erlang or JavaScript FFI implementations, the checker can't analyze the foreign code and will flag those calls as [Unknown]. Declare their effects with external effects in your .graded file:
// priv/graded/my_app.graded
external effects my_app/native.hash_password : [Crypto]
external effects my_app/native.read_env : [System]
external effects my_app/native.pure_helper : []
Since externals are loaded before inference, graded infer will propagate the declared effects through callers correctly — no [Unknown] noise.
// Comments use Gleam-style //
type Handler.on_click : [Dom]
check render_page : []
check handle_request : [Http, Stdout]
check safe_map(f: []) : []
effects render_page : []
effects handle_request : [Http, Stdout]
effects apply(f: [Stdout]) : [Stdout]
[]means pure — no effects allowed[Http, Dom]— these specific effects are permitted
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 |
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 handle_request : [Http, Email]
graded inferregenerateseffectslines while preservingchecklines,typelines, comments, and blank linesgraded formatnormalizes spacing and sorting
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_http | (pure) | — |
| gleam_httpc | gleam/httpc.send |
Http |
| gleam_json | (pure) | — |
| lustre | lustre.start, lustre.send, lustre/server_component.* |
Process, Dom |
| lustre_http | lustre_http.* |
Http |
| simplifile | simplifile.* |
FileSystem |
| filepath | (pure) | — |
| gleam_regexp | (pure) | — |
| gleam_yielder | (pure) | — |
| gleam_crypto | (pure) | — |
| tom | (pure) | — |
Module-level declarations like external effects gleam/list : [] mark an entire module as pure — any function from that module resolves as effect-free.
For packages not in the catalog, use external effects declarations in your project's .graded files:
// priv/graded/app.graded
external effects some_package/module.function : [Http]
external effects some_package/pure_module : []
To add a new package to the bundled catalog, create priv/catalog/{package}@{version}.graded with external effects declarations for its modules and functions. Only one version file per package is needed — the version resolution algorithm handles older installations.
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)The effect checker handles:
- Qualified and unqualified calls
- Pipe chains
- Closures and nested functions
- Case branches and guards
- Transitive local call analysis with cycle detection
- Parameter effect bounds for higher-order functions
- Type field effect annotations with type-aware resolution
- Dependency effect loading from
priv/graded/ - Structure-preserving
.gradedfile merging
graded performs syntax-level analysis using glance — it walks the AST without type information. This keeps the tool simple and avoids depending on compiler internals, but comes with trade-offs:
-
Function references passed as values are not tracked. If you write
list.map(items, io.println), graded seeslist.map(pure) but doesn't recognize thatio.printlncarries[Stdout]— it's passed as a value, not called. The effect is lost. Inline anonymous functions (list.map(items, fn(x) { io.println(x) })) work correctly because graded sees theio.printlncall directly in the function body. -
No effect polymorphism. You can't express "this function has whatever effects its argument has." Each
checkannotation declares a specific combination of parameter bounds. There's no way to write a genericmap(f: [e]) : [e]— you'd need separate annotations for each concrete effect set. -
Local transitive analysis is same-module only. If function
acallsbwhich callsc, and all are in the same module, effects are resolved transitively. But ifbis in another module and has no.gradedannotation, it resolves as[Unknown]. -
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, the common patterns (inline callbacks, direct calls, pipe chains) are handled correctly. The main gap is function references passed to higher-order functions — a pattern that can be worked around by inlining the callback or adding explicit annotations.
The current parameter bounds system requires concrete effect sets. A polymorphic system would allow effect variables:
effects map(f: [e]) : [e]
This would let one signature express that map propagates whatever effects its callback has, eliminating the need for per-use-case annotations. The checker would need effect unification — at each call site, bind e to the concrete effects of the argument and propagate upward.
The root cause of most limitations is the lack of type information. If the Gleam compiler exposed typed AST metadata (expression types, resolved function references), graded could:
- Track effects of function references passed as values (knowing that
io.printlninlist.map(items, io.println)is a function with[Stdout]) - Resolve field calls without requiring explicit type annotations on parameters
- Eliminate false
[Unknown]results from indirect calls
These two features — effect polymorphism and typed AST access — together would close the remaining soundness gaps and bring graded from syntax-level approximation to a complete effect system.
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