Skip to content

alvivi/graded

Repository files navigation

graded

Package Version Hex Docs CI

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.

Quick start

gleam add --dev graded

Infer effects for your project:

gleam run -m graded infer

This scans src/, analyzes every public function, and writes effect annotations to priv/graded/.

Lustre 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)])
}
// 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) 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.

General example

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.

How it works

Four annotation types in .graded files:

  • effects fn : [...] — inferred cache, auto-generated by graded infer, not enforced
  • check fn : [...] — invariant, enforced by graded check, violations break the build
  • type Type.field : [...] — declares effects for function-typed fields on custom types
  • external 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:

  1. Your .graded filesexternal effects declarations you write for your project
  2. Dependency .graded files — shipped by libraries in build/packages/*/priv/graded/
  3. Bundled catalog — versioned catalog files shipped with graded (see below)
  4. Conservative default — unknown functions get [Unknown]

Higher-order functions

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

Type field effects

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.

External declarations

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.

.graded file format

// 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

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 infer regenerates effects lines while preserving check lines, type lines, comments, and blank lines
  • graded format normalizes spacing and sorting

Effect catalog

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.

Covered packages

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.

Adding your own catalog entries

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 : []

Contributing catalog files

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.

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

Current scope

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 .graded file merging

Limitations

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 sees list.map (pure) but doesn't recognize that io.println carries [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 the io.println call directly in the function body.

  • No effect polymorphism. You can't express "this function has whatever effects its argument has." Each check annotation declares a specific combination of parameter bounds. There's no way to write a generic map(f: [e]) : [e] — you'd need separate annotations for each concrete effect set.

  • Local transitive analysis is same-module only. If function a calls b which calls c, and all are in the same module, effects are resolved transitively. But if b is in another module and has no .graded annotation, it resolves as [Unknown].

  • External code is opaque. Erlang/JavaScript FFI implementations, pre-compiled dependencies without .graded files, and dynamically dispatched calls cannot be analyzed. Use external effects declarations 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.

Future work

Effect polymorphism

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.

Typed AST integration

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.println in list.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.

Privacy and information flow checking

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.

License

Apache-2.0

About

Effect checking for Gleam

Topics

Resources

License

Stars

Watchers

Forks

Contributors

Languages