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.

Every result also reports which member each reference resolved to — a record field, a module function, constant or constructor under the module’s canonical path, a local variable, or unresolved — for every field access and every bare name in call position, and names every top-level definition left out of the build for the other Target.

Imported modules are resolved through a Resolver to obtain their public interfaces.

Types

Everything girard inferred for one module: each top-level definition’s signature, the type of every expression in their bodies, and what every field access and every bare name in call position resolved to.

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.

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: nothing is recorded inside a definition dropped names, and, for a record reached through annotate_package, nothing inside one the enclosing ModuleResult’s skipped names. The strict entry points return no AnnotatedModule at all when a definition fails, so from them every walked definition the module declares is in the record.

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 annotate_package, and "" for a module annotated on its own — the same name its own types carry in Named.

pub type AnnotatedModule {
  AnnotatedModule(
    functions: List(#(String, Scheme)),
    constants: List(#(String, Scheme)),
    expressions: List(Annotation),
    resolutions: List(ResolvedReference),
    dropped: List(Dropped),
  )
}

Constructors

  • AnnotatedModule(
      functions: List(#(String, Scheme)),
      constants: List(#(String, Scheme)),
      expressions: List(Annotation),
      resolutions: List(ResolvedReference),
      dropped: List(Dropped),
    )

    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.

    resolutions

    What every field access and every bare name in call position resolved to, sorted by span, one entry per span.

    dropped

    Top-level functions and constants dropped for the other build Target, with their spans, sorted by span.

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

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

A top-level function or constant left out of the build for the other Target: its name, and the span glance gave the definition — from its pub, fn or const keyword to its closing token. Everything written above that keyword is outside the span, including the doc comment and the @target attribute that is the reason the definition is here at all. It carries no target of its own — there are two, and the active one is in the caller’s Options.

pub type Dropped {
  Dropped(name: String, span: glance.Span)
}

Constructors

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

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

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, and nothing inside its span is annotated or resolved.

pub type ModuleResult {
  ModuleResult(
    annotated: AnnotatedModule,
    skipped: List(#(String, Error)),
  )
}

Constructors

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(receiver: 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(receiver: Type, label: String)

    A field of the accessed value’s nominal type — the compiler’s RecordAccess. receiver is the type of the value the field was read from, the thing left of the dot. The compiler calls that field record; this is the one place girard does not take its name, because here it holds a type rather than the accessed expression, and because the same word then has to name both the whole access and one half of it.

  • 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 imported as Close. The compiler calls this variant Record; Constructor is 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

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. module is "gleam" for prelude types.

  • Fn(arguments: List(Type), return: Type)

    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 a Scheme, vars identifies 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

  • RecordAccessUnknownType

    The receiver’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 Annotation at 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. girard defers where the compiler’s own inference has not fixed the receiver’s type at the access either — there the compiler rejects the program (Unknown type for record access) and girard is the more permissive of the two. Wherever the compiler pushes a known type into a lambda’s parameters before walking its body, girard now does too, so a receiver the compiler can type is one girard resolves at the access rather than after it.

    That is measured rather than promised: over the corpora girard is checked against — its fixtures/oracle/ and fixtures/golden/ fixtures, and a resweep of lustre, mist, wisp, glint, birl, gleam_otp, maud and shore — no accepted program reports this reason. A finite census is not a proof, so a new one over code the compiler accepts is a gap in girard’s inference order, and worth reporting as one.

Values

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.

pub fn with_target(options: Options, target: Target) -> Options

Type for target. @target(...) definitions that do not match are dropped, exactly as the compiler omits them from the build.

Search Document