A Gleam source type annotator, in Gleam!
Runs type inference over Gleam source — replicating the real Gleam compiler — and
reports the inferred type of every expression (by source span) together with each
top-level definition's signature. Parsing is delegated to
glance.
The project is stable: its inferred types are validated differentially against
the real compiler across the hex ecosystem (see PACKAGES.md).
The Gleam compiler infers a type for every expression, but it does not expose that information as a library: there is no API a tool can call to ask "what is the type of the expression at this span?". girard exists to answer exactly that question.
That makes it a building block for language tooling written in Gleam:
- Editor tooling and language servers — code actions, hovers, and completions that need the type of the expression under the cursor. For example, a "wrap this element" refactor only makes sense when it knows the expression already has the element type it is wrapping.
- Linters and analyzers — rules that depend on types rather than syntax alone, without reimplementing inference.
- Code generation and refactoring tools — codemods that must know a binding's signature to rewrite call sites safely.
Because girard consumes glance ASTs and keys
its annotations by source span, a tool that already parses with glance can join
inferred types directly back onto its own AST — no compiler invocation, no AST
fork, no parsing twice.
Add the package to your Gleam project:
gleam add girardThen annotate some source:
import girard
import gleam/io
const code = "pub fn double(x) { x + x }"
pub fn main() {
io.println(girard.report(code))
}This program outputs the following to the console:
double: fn(Int) -> Int
19-20: Int
19-24: Int
23-24: Int
report is the quick, human-readable rendering. For programmatic use,
girard.annotate(code, girard.default_options()) returns a structured
AnnotatedModule: each top-level definition's Scheme (in functions /
constants) and every expression's Type keyed by its source span (in
expressions). These are structured girard values —
pattern-match on Named/Fn/Var/Tuple, or render one with
girard.type_to_string.
gleam run -- path/to/file.gleam # annotate a file
gleam run -- - # annotate stdin
cat file.gleam | gleam run # annotate stdin
gleam run -- --help # usageImports are resolved from src/ and build/packages (so import gleam/list
works); ill-typed input prints a single // error: … line.
If you have already parsed the source with
glance, hand the glance.Module to
girard.annotate_module instead of a source string, so the source is parsed
once, not twice. Each expression Annotation carries a glance.Span — the same
span glance puts on every AST node — so you join the inferred types onto your own
tree by span, and inspect them as structured values.
import girard.{type Type, Fn, Named}
import glance
import gleam/dict.{type Dict}
import gleam/list
/// Parse once with glance, then annotate that AST. Returns each expression's
/// inferred type keyed by its glance span, to join onto your own AST nodes.
pub fn types_by_span(source: String) -> Dict(#(Int, Int), Type) {
let assert Ok(module) = glance.module(source)
let assert Ok(annotated) =
girard.annotate_module(module, girard.default_options())
list.fold(annotated.expressions, dict.new(), fn(acc, a) {
dict.insert(acc, #(a.span.start, a.span.end), a.type_)
})
}
/// A definition's generalized signature is a structured `Scheme` (`.type_` is
/// the type, `.vars` are its quantified type-variable ids) you can pattern-match.
pub fn return_kind(source: String, name: String) -> String {
let assert Ok(module) = glance.module(source)
let assert Ok(annotated) =
girard.annotate_module(module, girard.default_options())
case list.key_find(annotated.functions, name) {
Ok(scheme) ->
case scheme.type_ {
Fn(_args, Named("gleam", "Int", [])) -> "returns Int"
Fn(_args, Named("gleam", "List", [_])) -> "returns a List"
Fn(_args, other) -> girard.type_to_string(other)
other -> girard.type_to_string(other)
}
Error(_) -> "no such function"
}
}(Imported modules are still parsed internally, via the resolver — only the module you pass is taken pre-parsed.)
annotate, annotate_module, and annotate_package all take an Options
value. Build it from girard.default_options() (disk resolver, Erlang target)
and customize it with the with_* setters:
girard.default_options()
|> girard.with_target(girard.JavaScript) // type for the JS target
|> girard.with_resolver(fn(_) { Error(Nil) }) // resolve no importsThe resolver is fn(module_path) -> Result(source, Nil); inject your own to
resolve imports from anywhere (an in-memory map, a build tree, …).
An editor or package-walking tool can carry a Cache between annotations so
shared imports are parsed and inferred once:
let options = girard.default_options()
let cache = girard.new_cache()
let #(first_result, cache) =
girard.annotate_with_cache(first_source, options, cache)
let #(second_result, cache) =
girard.annotate_with_cache(second_source, options, cache)A cache assumes the same resolver and target for its whole lifetime. When an imported module changes, invalidate its module path before the next call:
let cache = girard.invalidate(cache, "my_app/shared")invalidate removes only that module. If its public interface changed, also
invalidate cached importers, or start again from new_cache().
girard.annotate_package(modules, options) annotates many modules in one pass,
inferring a shared import only once across the whole run. modules is a list of
#(module_path, glance.Module); the result maps each path to a ModuleResult
(.annotated plus .skipped).
Unlike annotate/annotate_module, it is best-effort per definition: a
top-level function or constant that does not type — along with anything that
depends on it — is listed in that module's .skipped (with the error that
declined it) rather than failing the module, and every other definition is still
annotated. A strict check is just result.skipped == [].
The resolver must be able to load package-local imports as well as external
dependencies. Supplying a module in modules gives girard its AST to annotate;
it does not implicitly add that source to the resolver. An in-memory package can
provide both views from one source table:
import girard
import glance
import gleam/dict
import gleam/list
let sources =
dict.from_list([
#("my_app/a", "pub fn answer() { 42 }"),
#(
"my_app/b",
"import my_app/a\npub fn answer() { a.answer() }",
),
])
let resolver = fn(path) { dict.get(sources, path) }
let modules =
sources
|> dict.to_list
|> list.map(fn(entry) {
let #(path, source) = entry
let assert Ok(module) = glance.module(source)
#(path, module)
})
let options =
girard.default_options()
|> girard.with_resolver(resolver)
let results = girard.annotate_package(modules, options)-
Parsing is bounded by
glance. girard does not parse Gleam itself, so source thatglancecannot parse, girard cannot annotate. Since imports are resolved by parsing, an unparseable module also makes its dependents fail withunbound variable. The gaps the sweep surfaces are all in bit-array syntax — chiefly arithmetic in a bit-array pattern segment size, e.g.<<value:size(len - 1)>>(the construction side parses, the pattern side does not). These areglancelimitations, not girard inference errors. -
Inferred types, not diagnostics. girard reproduces the types the compiler infers, but it is not a full type checker: when a module cannot be typed it returns a single
Errorfor the first problem found, not the compiler's full set of diagnostics. -
Scoped to compilable code. Inference is validated against programs the real compiler accepts; packages that do not compile with current tooling are out of scope, since the compiler cannot type them either.
See CONTRIBUTING.md for the development workflow,
differential testing, and code and commit conventions. See
AGENTS.md for the architecture, inference pipeline, state model,
and design decisions.
API documentation is available at https://hexdocs.pm/girard.