graded

Package Version Hex Docs CI

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.

Quick start

gleam add --dev graded

Infer effects for your project:

gleam run -m graded infer

This scans src/, analyses every function, and writes two outputs:

Example

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) found

Remove 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).

Configuration

graded reads its configuration from a [tools.graded] table in gleam.toml. Every field is optional — omit them to get the defaults.

[tools.graded]
spec_file = "myapp.graded"          # default: "<package_name>.graded"
cache_dir = "build/.graded"         # default: "build/.graded"
targets = ["erlang", "javascript"]  # default: the top-level `target`, or "erlang"

targets decides which @external declarations graded treats as built. By default it follows gleam.toml’s top-level target; where that is absent too, graded reads Gleam fallback bodies on erlang — what the compiler builds when nothing says otherwise — while still reading every @external declaration on both targets, since a --target build is invisible to it. Set it if your package is really built for both: an @external declared for one target then has its Gleam fallback body reached on the other, and callers are charged both.

Publishing your spec file to consumers

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 printed

pack 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:

Reference

The .graded spec language and graded’s analysis model are documented in full in the Reference — the annotation kinds (effects, check, type, external effects, external returns, 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.

Commands

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 why <name> [directory]    # explain a function's effects, writing nothing
gleam run -m graded catalog                   # list graded's bundled catalog files
gleam run -m graded catalog <package>         # print the catalog file selected for <package>
gleam run -m graded catalog <package>@<version> # print exactly that bundled catalog file
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 version

An 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.

why explains one function instead of answering for one name: it re-walks the function’s body and prints a line per effect contributor — what the call is, the effects it contributes, and either why they stayed unresolved or which source resolved them, in the same wording violations use. It explains a function whether or not it has a check line and whether or not it fits one, so an effect you didn’t expect is traced without first writing a budget to make it fail. Its <name> is a module-qualified function of one of your own modules (private ones included, unlike effect); a dependency function has no body here to walk. A function with two check lines gets one block per line, each analysed under that line’s own bounds. Nothing is written.

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). A directory inside a package’s src/ narrows what is reported, not what is analysed: the whole package is resolved (module paths, imports and @external discovery are package-wide facts) and check reports only the passed subtree’s files, while infer, which writes one package-level spec, writes the whole package’s. To check another project, run graded from that project’s root or point it at its src/.

Limitations

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.

License

Apache-2.0

Search Document