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.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] (from gleam_stdlib's catalog entry) 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).
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"Gleam can't ship a package-root file like myapp.graded on a hex release — a published package includes src/, gleam.toml, the README, and the licence, with no configuration key to add more (a known Gleam limitation). The spec has to be injected into the release tarball after it's built, which is what graded pack does:
gleam export hex-tarball # build the release tarball
gleam run -m graded pack # inject <spec_file> into it, then publish as printedpack places your spec at build/packages/<your-package>/<spec_file> in downstream projects — where graded's resolver already looks — so consumers need no setup. It patches build/<name>-<version>.tar in place (the graded.pack_project API also accepts an explicit tarball path) and prints the Hex publish API command to run next. Do not run gleam publish afterwards: it rebuilds the tarball from source and drops the injected spec. Documentation still publishes via gleam docs publish. The cache directory under build/ is gitignored and never ships.
Two cases need no packing:
- Path dependencies. A
{ path = "..." }dependency's root spec is read straight from its checkout. - Common packages. graded bundles a catalogue of effect specs for popular packages, so many dependencies resolve with no spec of their own.
The .graded spec language and graded's analysis model are documented in full in the Reference — the annotation kinds (effects, check, type, external effects, returns), effect-set syntax, effect resolution order, higher-order and second-order effect polymorphism, type field effects, the effect-label conventions, and the bundled catalog of common packages.
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 infer --dry-run [directory] # preview the spec changes, writing nothing
gleam run -m graded effect <name> [directory] # look up one effect, writing nothing
gleam run -m graded effect <name> --format=graded # ... as a .graded line instead of prose
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)
gleam run -m graded -- --help # show usage (-- passes the flag through gleam run)
gleam run -m graded -- --version # show the installed versionAn unknown command or option is a usage error, not a silently-checked directory.
effect answers a single lookup and writes nothing — the spec file and the cache are left untouched. Its <name> is either a module-qualified function (myapp/router.handle) or a type field (myapp/repo.Repo.find). It prints prose by default (myapp/router.handle has effects [Stdout]), describing where a higher-order function's effects come from and what its bounds assume, and stating a [Unknown] result as a name that was found whose effects weren't determined. --format=graded prints the same answer as a .graded line with provenance on a // comment, so it parses back — the format to pipe into a spec file. Public functions resolve without a prior graded infer; private functions and undeclared type fields report that the name wasn't found. A module covered by a module-level external effects <module> declaration is the exception: that declaration answers for every name in the module that nothing else keys, so such a name resolves to the declared effect whether or not it exists.
infer --dry-run previews the same inference as a line diff of the spec file — the -/+ lines with a couple of lines of context around them, or graded: no changes — and writes nothing, neither the spec file nor the cache. It exits 0 either way; format --check is the CI gate.
check and infer scope to the passed directory (default src/), recursing into it but never into build/. Passing the package root — graded check . — scopes to the root's src/, so module names come out as they appear in import statements (app, not src/app). To check another project, run graded from that project's root or point it at its src/.
graded is sound, not complete: it combines syntax-level analysis (glance) with type information (girard), and when it can't statically trace a function value it falls back to the [Unknown] effect rather than guess. [Unknown] fails an effect budget, so graded never silently understates effects — but a few value-flow patterns need a hand-written annotation or a wider budget to resolve.
Idiomatic Gleam — inline callbacks, direct and aliased function references, pipe chains, higher-order functions passing functions by name (including second-order operator effects), and validator/handler/config records — is handled automatically, including across modules: a fresh checkout resolves transitive chains with no prior graded infer (committed effects lines always win, and check writes nothing to disk).
The handful of patterns that fall back to [Unknown] — each with how it shows up and how to work around it — are documented in Limitations.
Apache-2.0