girard
A type annotator for Gleam, written in Gleam.
Reports the inferred type of every expression — keyed by its source span —
and the signature of every top-level function and constant, for a single
module (annotate) or a whole package
(annotate_package). Give it source text or a
glance AST you parsed yourself.
Imported modules are resolved through a Resolver to obtain
their public interfaces.
Types
Everything girard inferred for one module — its
AnnotatedModule — plus what every field access and
every bare name in call position resolved to, and which definitions girard
declined.
resolutions is sorted by span, with one entry per span. The contract is
exact: an entry is recorded for every glance.FieldAccess girard walked,
wherever it sits, and for every bare name in call position — the callee of
a call, a capture or a use, and a bare pipe target. Nothing else is
recorded, so a name read outside call position (let g = greet), the
constructor of a record update or of a pattern, and a tuple index have no
entry. A span with no entry was therefore either not a recorded position or
never walked: a definition in skipped contributes none, and neither does
one dropped for the other build Target.
A resolution names a module by its canonical path, never the alias it was
imported under. The module under analysis is named as girard was given it:
its path under analyse_package, and "" for a module
analysed on its own — the same name its own types carry in Named.
skipped names each top-level function or constant girard declined, with
the error that declined it. It is always empty from
analyse, analyse_module and
analyse_with_cache, which fail the whole module
instead; see analyse_package for the best-effort rule.
pub type Analysis {
Analysis(
annotated: AnnotatedModule,
resolutions: List(ResolvedReference),
skipped: List(#(String, Error)),
)
}
Constructors
-
Analysis( annotated: AnnotatedModule, resolutions: List(ResolvedReference), skipped: List(#(String, Error)), )
Everything girard inferred for one module: each top-level definition’s signature, plus the type of every expression in their bodies.
functions and constants have one entry per top-level definition — its
generalized Scheme (a type_ plus the ids of its quantified Vars).
expressions is finer-grained: the Type of every expression — literals,
calls, operators, sub-expressions — keyed by its glance source span, so
you can join inferred types onto your own AST. Render any type with
type_to_string.
pub type AnnotatedModule {
AnnotatedModule(
functions: List(#(String, Scheme)),
constants: List(#(String, Scheme)),
expressions: List(Annotation),
)
}
Constructors
-
AnnotatedModule( functions: List(#(String, Scheme)), constants: List(#(String, Scheme)), expressions: List(Annotation), )Arguments
- functions
-
Top-level function name to inferred signature scheme, in source order.
- constants
-
Top-level constant name to inferred scheme, in source order.
- expressions
-
Expression span to inferred type, sorted by start offset.
The inferred type of a single expression, identified by its source span.
type_ is a structured Type you can pattern-match on; render it with
type_to_string.
pub type Annotation {
Annotation(span: glance.Span, type_: Type)
}
Constructors
-
Annotation(span: glance.Span, type_: Type)
A reusable cache of inferred module interfaces, threaded across
annotate_with_cache calls. Annotating a module
infers every module it imports — transitively — to obtain their interfaces;
without a shared cache each call repeats that work, so a tool re-checking a
module or walking a package re-infers the same dependencies again and again.
Carrying a Cache between calls infers each imported module once and reuses
it thereafter.
A cache keys interfaces by module path and assumes a fixed
Resolver and Target: do not reuse one across
different resolvers or targets, or it would hand back interfaces built from
the wrong sources. Create one with new_cache; when a module’s
source changes, drop it with invalidate.
pub opaque type Cache
Why a module could not be typed. Variants describe the failure in terms of the type system and the offending source construct.
pub type Error {
TypeMismatch(left: Type, right: Type)
ArityMismatch
RecursiveType(id: Int, type_: Type)
UnboundVariable(name: String)
UnknownConstructor(name: String)
UnknownModule(alias: String)
NoSuchExport(module: String, name: String)
NoSuchField(type_name: String, label: String)
NotARecord
NotATuple
TupleIndexOutOfRange(index: Int)
UnknownLabel(label: String)
AmbiguousCall
MissingArgument
Unsupported(feature: String)
ParseFailed(glance.Error)
}
Constructors
-
-
ArityMismatch -
RecursiveType(id: Int, type_: Type) -
UnboundVariable(name: String) -
UnknownConstructor(name: String) -
UnknownModule(alias: String) -
NoSuchExport(module: String, name: String) -
NoSuchField(type_name: String, label: String) -
NotARecord -
NotATuple -
TupleIndexOutOfRange(index: Int) -
UnknownLabel(label: String) -
AmbiguousCall -
MissingArgument -
Unsupported(feature: String) -
ParseFailed(glance.Error)
The result of annotating one module of a package: its
AnnotatedModule plus the definitions that could not be
typed. skipped names each top-level function or constant girard declined,
with the error that declined it; a definition in skipped is absent from
annotated.
pub type ModuleResult {
ModuleResult(
annotated: AnnotatedModule,
skipped: List(#(String, Error)),
)
}
Constructors
-
ModuleResult( annotated: AnnotatedModule, skipped: List(#(String, Error)), )
How a module is annotated: which Resolver finds imported
modules, and which build Target to type for. Build one from
default_options and customize it with
with_target and with_resolver:
default_options()
|> with_target(JavaScript)
pub opaque type Options
Which member a reference resolved to. The variants are named after the
compiler’s ValueConstructorVariant, which is where the same distinction
lives there, plus RecordField for the case the compiler reaches through
RecordAccess rather than through a scope entry.
pub type Resolution {
RecordField(record: Type, label: String)
ModuleFn(module: String, name: String)
ModuleConstant(module: String, name: String)
Constructor(module: String, name: String)
LocalVariable(name: String)
Unresolved(reason: UnresolvedReason)
}
Constructors
-
RecordField(record: Type, label: String)A field of the record’s nominal type — the compiler’s
RecordAccess, whose accessed value it likewise calls therecord. -
ModuleFn(module: String, name: String)A module’s function, under the module’s canonical path.
-
ModuleConstant(module: String, name: String)A module’s constant, under the module’s canonical path.
-
Constructor(module: String, name: String)A custom-type constructor, under its defining module’s canonical path and its declared name —
Near, even where it was importedas Close. The compiler calls this variantRecord;Constructoris the name it uses for the same thing wherever it faces outwards. -
LocalVariable(name: String)A local binding: a
let, a parameter, or a pattern’s binding. -
Unresolved(reason: UnresolvedReason)girard reached no member at the reference. The reason says why.
One reference and what it resolved to.
span is the whole access — the same span Annotation
carries for it — while label_span and container_span are the member and
the accessed value, named as the compiler names the two spans it computes
for a field access. For a bare name in call position the three are the same
span.
Note that the compiler’s own Reference is a different notion: it records
the syntax a use took — qualified, unqualified, aliased — for renaming and
find-references. A ResolvedReference says which member the use reached,
and says nothing about how it was spelled.
pub type ResolvedReference {
ResolvedReference(
span: glance.Span,
label_span: glance.Span,
container_span: glance.Span,
resolution: Resolution,
)
}
Constructors
-
ResolvedReference( span: glance.Span, label_span: glance.Span, container_span: glance.Span, resolution: Resolution, )
Resolves an imported module path (e.g. "gleam/list") to its source.
pub type Resolver =
fn(String) -> Result(String, Nil)
A polymorphic type scheme forall vars. type_: the generalized type of a
top-level function or constant. vars are the ids of the Vars in type_
that are universally quantified (generic); a monomorphic binding is
Scheme([], type_).
pub type Scheme {
Scheme(vars: List(Int), type_: Type)
}
Constructors
-
Scheme(vars: List(Int), type_: Type)
The build target a module is compiled for. The target is a whole-build
setting in Gleam, so it applies to every module in one annotation run.
Definitions and imports annotated @target(...) are kept only when they
match the active target. default_options() selects Erlang (matching
gleam build’s default); use with_target for JavaScript.
pub type Target {
Erlang
JavaScript
}
Constructors
-
Erlang -
JavaScript
The structured type girard infers for an expression or definition. Pattern-
match on its variants to inspect an inferred type, or render it to Gleam
syntax with type_to_string.
pub type Type {
Named(module: String, name: String, arguments: List(Type))
Fn(arguments: List(Type), return: Type)
Var(id: Int)
Tuple(elements: List(Type))
}
Constructors
-
Named(module: String, name: String, arguments: List(Type))A named, nominal type such as
Int,List(a),Result(a, e)or a user-defined custom type.moduleis"gleam"for prelude types. -
A function type
fn(a, b) -> c. -
Var(id: Int)A type variable identified by
id. During inference the substitution table may bind that id to another type; in aScheme,varsidentifies which ids are universally quantified (generic). -
Tuple(elements: List(Type))A tuple type
#(a, b, c).
Why girard reached no member at a reference.
pub type UnresolvedReason {
RecordAccessUnknownType
}
Constructors
-
RecordAccessUnknownTypeThe record’s type was unknown at the access and no module of its name exported the label, so girard deferred the access and read the field only once later inference had fixed the type — after the point where a member could be named. The
Annotationat the span is still girard’s answer for the field’s type; only the member is unreported.Named for the compiler error covering the same ground,
RecordAccessUnknownType. Where the compiler’s inference had not fixed the record’s type at the access either, it rejects the program there (Unknown type for record access) and girard is the more permissive of the two. Where it had, girard merely reached the answer later than the compiler did.
Values
pub fn analyse(
source: String,
options: Options,
) -> Result(Analysis, Error)
Analyse a Gleam source string: parse it with glance, then analyse as
analyse_module. This is annotate plus
what every field access and every bare name in call position resolved to —
see Analysis for the exact contract.
pub fn analyse_module(
module: glance.Module,
options: Options,
) -> Result(Analysis, Error)
Analyse an already-parsed glance.Module, as
annotate_module annotates one, and additionally report
what every field access and every bare name in call position resolved to.
The spans in ResolvedReference are glance’s, so they
line up with your own AST’s nodes. See Analysis for the exact
contract; skipped is always empty here, because a module that does not
type is an error rather than a partial result.
pub fn analyse_package(
modules: List(#(String, glance.Module)),
options: Options,
) -> dict.Dict(String, Analysis)
Analyse every module in a package in one pass, as
annotate_package annotates one, and additionally
report what every field access and every bare name in call position
resolved to — see Analysis for the exact contract.
Best-effort per definition, on the same rule: a top-level function or
constant that does not type — along with any that depend on it — is reported
in that module’s skipped rather than failing the module. A skipped
definition contributes no resolutions, so no reference falls inside its
span, while every other definition is still analysed.
pub fn analyse_with_cache(
source: String,
options: Options,
cache: Cache,
) -> #(Result(Analysis, Error), Cache)
Analyse a source string like analyse, but reuse and extend
cache exactly as annotate_with_cache does. The
cache holds imported modules’ interfaces, which the resolutions of the
module under analysis are read from; it carries no resolutions of its own.
pub fn annotate(
source: String,
options: Options,
) -> Result(AnnotatedModule, Error)
Annotate a Gleam source string: parse it with glance, then annotate as
annotate_module. Returns the inferred error if the
module does not type. The quick path is annotate(source, default_options()).
pub fn annotate_module(
module: glance.Module,
options: Options,
) -> Result(AnnotatedModule, Error)
Annotate an already-parsed glance.Module. Use this when you have parsed the
source with glance yourself — the returned spans are glance’s, so they line
up with your AST’s node spans and you avoid parsing the same source twice.
(Imported modules are still parsed internally, via the resolver.) Returns the
inferred error if the module does not type; for partial results on an
ill-typed module, use annotate_package.
pub fn annotate_package(
modules: List(#(String, glance.Module)),
options: Options,
) -> dict.Dict(String, ModuleResult)
Annotate every module in a package in one pass, sharing inference of common
imports across modules. modules maps each module’s path (e.g.
"my_app/router") to its parsed glance.Module; the result maps the same
paths to a ModuleResult.
This is the batch counterpart to annotate_module: a
dependency imported by several modules is inferred once for the whole run
rather than once per
importing module. Cross-module references within the package are resolved
through the options’ resolver, so it must also resolve the package’s own
modules (a resolver wrapping the build’s module sources does); a module
reached only that way is inferred for its interface and again here for its
annotations.
Best-effort per definition: a top-level function or constant that does not
type — along with any that depend on it — is reported in that module’s
skipped list rather than failing the module, while every other definition
is still annotated. Definition failures therefore leave the module present
in the result; a fully strict check is result.skipped == [].
pub fn annotate_with_cache(
source: String,
options: Options,
cache: Cache,
) -> #(Result(AnnotatedModule, Error), Cache)
Annotate a source string like annotate, but reuse and extend
cache: imported modules already inferred in it are taken from the cache
rather than resolved and inferred again, and any newly inferred ones are
added. Returns the result and the updated cache to thread into the next call.
annotate_with_cache(source, options, new_cache()) matches
annotate(source, options) exactly; the cache only pays off
when shared across calls that import overlapping modules — an editor
re-checking a file as it changes, or a walk over a package’s modules.
pub fn default_options() -> Options
Default options: resolve imports from disk (disk_resolver()) and type for
the Erlang target (matching gleam build’s default).
pub fn describe_error(error: Error) -> String
A short, human-readable description of an inference error.
pub fn disk_resolver() -> fn(String) -> Result(String, Nil)
The default resolver: looks for an imported module’s source under src/
first, then the build/packages/*/src dependency sources, relative to the
current working directory. Constructing the resolver touches no filesystem;
the build/packages listing and every source read happen lazily, when the
resolver is invoked. A missing build/packages or an unreadable source is
not an error here — the import is simply not found, surfaced as Error(Nil)
at resolution time.
pub fn invalidate(cache: Cache, path: String) -> Cache
Drop the cached interface for path (the module path, e.g.
"my_app/router"), so the next annotate_with_cache
that needs it re-infers it from source. Use this when a module changes.
Only the named module is dropped. A cached module that imports the changed
one keeps its own (now possibly stale) interface, so after a change that
alters a module’s public surface, also invalidate its importers — or start
from a new_cache.
pub fn main() -> Nil
gleam run -- <file.gleam> annotates a file; gleam run -- - (or no
arguments, or piped input) annotates stdin. Imports are resolved from disk.
pub fn new_cache() -> Cache
An empty Cache to seed a run of
annotate_with_cache calls.
pub fn report(source: String) -> String
Annotate a source string and render the result as a human-readable text
report (signatures and per-expression types). On failure the report is a
single // error: line.
Example
report("pub fn double(x) { x + x }")
double: fn(Int) -> Int
19-20: Int
19-24: Int
23-24: Int
pub fn type_to_string(type_: Type) -> String
Render an inferred Type to Gleam syntax (e.g. fn(Int) -> a), naming type
variables a, b, c, …. Each call names variables independently: an a in
one rendered type is unrelated to an a in another.
pub fn with_resolver(
options: Options,
resolver: fn(String) -> Result(String, Nil),
) -> Options
Resolve imported modules with resolver — e.g. fn(_) { Error(Nil) } to
resolve none, or a custom in-memory resolver.