stlc design
Design goal
stlc is a small, complete instance of a typed calculus on the shared
substrate: it reuses the named syntax, substitution, rewriting and the
untyped reducer, and adds what types make possible: a decidable type checker
and a normalizer that needs no step limit and returns canonical
(beta-normal, eta-long) forms. It is the model for richer typed cores built on
type_theory.
Mathematical background
Types and terms
Base types are uninterpreted names. Lambdas carry no type annotation (Curry style). A signature gives the types of constants , a context the types of free variables; in both, a later entry for a name shadows an earlier one, written .
Declarative typing
The judgement is given by the standard rules ( is left implicit):
Equality of terms is -conversion, with and for , both at well-typed instances.
Classical results
- Subject reduction. If and then .
- Strong normalization. Every reduction sequence of a well-typed term is finite (Tait’s method of computability predicates).11 W. W. Tait, “Intensional interpretations of functionals of finite type I”, Journal of Symbolic Logic 32, 1967.
- Canonical forms. Every well-typed term is -equal to a unique term in beta-normal, eta-long form, defined below.
Design decisions
Bidirectional type checking
Problem. Without annotations on lambdas, the type of is not determined, and full type inference (unification) is more than the calculus needs.
Choice. Two mutually recursive judgements: inference
(infer) and checking
(check). Information flows from the
expected type into lambdas, and from variables and constants out of
applications.22 J. Dunfield and N. Krishnaswami, “Bidirectional typing”, ACM Computing Surveys 54(5), 2021. The implemented rules are
In the spine is flattened first, so nested Apply nodes are one
application ; if has fewer arrows than arguments the
result is ExpectedFunction. In with the second
premise is .
applies to every term except a lambda checked against an arrow; a lambda in
inference position fails with CannotInferLambda. Type equality in
is syntactic, which is exact for simple types.
Why the extra rule. Plain bidirectional typing cannot infer , because the head is a lambda. The rule treats the redex like : the argument’s type is inferred and given to the parameter. It makes terms produced by substitution-style programming, such as , checkable without annotations.
Soundness of the checker
Theorem. If check(Σ, Γ, t, τ) succeeds, then ;
if infer(Σ, Γ, t) returns , then — provided
every use of satisfies .
Proof sketch. Induction on the algorithmic derivation. and the axioms map to their declarative counterparts; is immediate; is uses of the declarative application rule. For , invert the second premise: and for . Then
The strengthening step is where the side condition is used, and the implementation does not enforce it: for ,
has declarative type (the outer has type ), but infer types the
second argument in and returns Unit.
normalize_eta_long then rejects the term at either type, because its
evaluator checks the arguments in the original context. This is a known issue
of the current implementation (recorded in the correctness checklist);
renaming the parameter apart from the free variables of the remaining
arguments avoids it.
Completeness for normal forms. If is beta-normal and
, then check(Σ, Γ, t, τ) succeeds. A beta-normal
term is either a lambda, handled by , or a spine whose head is a
variable, a constant or ; its type is determined by or
and its arguments are again normal, so and induction apply.
Terms with redexes are accepted when each redex’s first argument is
inferable; is rejected with
CannotInferLambda although it is typable.
The checker terminates: every recursive call is on a strictly smaller term ( recurses on , which is smaller than the redex).
Two normalizers with different jobs
normalize_checked reuses the untyped normal-order beta-eta reducer of
utlc/lambda after checking the type. Its value is that it is
the reference semantics, with traces and step counts; it is step-bounded
only because it is shared with the untyped calculus. By strong normalization
it always ends in NormalForm for a large enough limit. Its normal forms are
eta-short.
normalize_eta_long is typed normalization by evaluation. It needs no limit,
and it returns the canonical representative of the class, so two
well-typed terms are -equal iff their eta-long normal forms are
alpha-equivalent: the normalizer decides conversion.
Beta-normal, eta-long forms
Normal forms and neutral terms are defined by type:
Every term of arrow type is a lambda (eta-long), and every application has a variable or a constant at its head (beta-normal). Neutral terms of type are kept: the eta law for the unit type ( for every ) is not implemented, so for and the terms and have different normal forms.
Typed normalization by evaluation
The semantic domain interprets each type:
where a closure stores a lambda body with its environment and its type, and a semantic neutral is a free variable, a constant, or a neutral applied to a value together with the value’s type. Evaluation maps variables through the environment, lambdas to closures, constants to neutrals, and applies closures by evaluating their bodies (call by value, which is safe because evaluation of typed terms terminates). Reflection embeds a neutral as a value; here it is the identity on neutrals, because all eta expansion is deferred to reification. Reification is
and the normal form of is , where maps each to . Reifying at an arrow type applies the value to a fresh variable, which performs eta expansion; a neutral application records the domain type of its argument so that the argument can be reified at the right type later.33 U. Berger and H. Schwichtenberg, “An inverse of the evaluation functional for typed λ-calculus”, LICS 1991. The type-directed presentation follows A. Abel, Normalization by Evaluation: Dependent Types and Impredicativity, habilitation thesis, 2013.
Correctness (sketch). Define a Kripke logical relation between terms and values, monotone under context extension:
By induction on one proves two lemmas together: reflection ( implies ) and reification ( implies ). The arrow case of reification is the eta step:
using (reflection) and the definition of . The fundamental lemma states that and imply ; it is proved by induction on the typing derivation, the lambda case using that beta-reduction is in . With the identity and (related by reflection), reification gives (soundness). Since evaluation identifies -equal terms (beta is function application in the model, eta holds because reification always expands), equal terms have equal normal forms (completeness). The same relation, read as a computability predicate, shows that evaluation terminates on well-typed terms, which is why no fuel is needed.
Fresh names in readback
Readback invents binder names with fresh_name("x", used), where used
contains every name of the input term and context, the names in closure
environments, and the names already introduced on the path. Generated names
are therefore distinct from each other along a path and from all names of the
input, and a neutral variable is never captured by a binder introduced later.
Correctness / invariants
checkandinferterminate; on success, the term is declaratively typable at the reported type, subject to the side condition of (see the known issue above).checkaccepts every well-typed beta-normal term.normalize_eta_longreturnsOkexactly for terms thatcheckaccepts, except for the known issue; its result is in , is -equal to the input, and is the same (up to ) for -equal inputs.normalize_checkedreturnsErrbefore any reduction for ill-typed input.NormalizationErrorsignals a violated internal invariant and is not produced for checked input.
The tests in src/stlc/stlc_test.mbt cover typing and rejection, shadowing,
eta expansion of open variables and of constants (including nested arrow
types and higher-order arguments), and agreement of the two normalizers on
small terms.
Alternatives rejected
- Church-style annotated lambdas. Annotations would make inference
complete but change the shared
Termsyntax; the bidirectional checker keeps terms unannotated. - Hindley–Milner inference. Unification would infer types of unannotated lambdas, but polymorphism and type variables are beyond this calculus.
- Fuel for typed NbE. Unnecessary by strong normalization; the untyped utlc/nbe keeps fuel because it needs it.
- Eta for the unit type. Implementable by reifying every neutral of type as ; not done, so that neutrals stay observable. The decided equality is for arrows only.
Boundaries
- Types are , and arrows: no products, sums, polymorphism or dependent types.
- No unit eta; normal forms are eta-long for arrow types only.
- Constants are opaque: there are no delta rules.
- The rule does not rename its parameter apart from later arguments (known issue).
normalize_checkedis bounded by a step limit because it reuses the untyped reducer.
Footnotes
-
W. W. Tait, “Intensional interpretations of functionals of finite type I”, Journal of Symbolic Logic 32, 1967. ↩
-
J. Dunfield and N. Krishnaswami, “Bidirectional typing”, ACM Computing Surveys 54(5), 2021. ↩
-
U. Berger and H. Schwichtenberg, “An inverse of the evaluation functional for typed λ-calculus”, LICS 1991. The type-directed presentation follows A. Abel, Normalization by Evaluation: Dependent Types and Impredicativity, habilitation thesis, 2013. ↩