The Simply-Typed Lambda Calculus
Untyped Lambda Calculus is unsettlingly powerful. It can express any computable function. But it can also express `(λx. x x) (λx. x x)`. This is a term that runs forever without ever producing a value. A compiler or a proof assistant cannot tolerate this ambiguity. It needs to know, before running anything, whether a program will misbehave. It must know if a program will loop forever, or apply a number as if it were a function. The Simply-Typed Lambda Calculus, usually written STLC or , answers this need. It attaches a type to every term. It rejects programs whose pieces do not fit together. The payoff is dramatic. Restricting attention to well-typed terms buys two guarantees simultaneously. Nothing goes wrong at runtime (no "stuck" applications of non-functions). Also, every well-typed program terminates. That second property — strong normalization — is remarkable. This is because the untyped calculus underlying it is Turing-complete. It therefore contains nonterminating terms. Typing does not just catch bugs. It provably tames the computational power of the language. It trades unrestricted expressiveness for a decidable, terminating core. STLC is the seed from which essentially every statically typed programming language grows. Function types with arrow syntax, the substitution lemma, progress and preservation, the very shape of a type-checking algorithm — all of it appears here first. It appears in its purest form, before being layered with polymorphism, records, recursion, and effects. Understanding STLC is understanding the skeleton beneath ML, Haskell, Rust's core calculus. It is also the skeleton beneath the type theory behind proof assistants like Coq and Agda. This article builds STLC from the ground up. It covers its syntax, its typing rules as an inference system, and the algorithm that checks types mechanically. It also covers the metatheory (progress, preservation, and normalization). This metatheory explains why well-typed programs cannot go wrong. Along the way we trace concrete terms through the type checker and through evaluation. We also flag the classic mistakes that trip up every student encountering typed calculi for the first time.
- STLC extends untyped Lambda Calculus with a base type and a function-type constructor .
- Typing judgments are derived by syntax-directed inference rules, one per term constructor.
- Type checking is decidable and can be implemented as a simple recursive algorithm over the term's structure.
- Progress: a well-typed closed term is either a value or can take a reduction step — it never gets stuck.
- Preservation: reduction preserves types — if and , then .
- Progress + preservation give type safety; a separate, deeper argument (logical relations) gives strong normalization.
Attaching function types to lambda terms and checking them structurally guarantees that well-typed programs never get stuck and always terminate.
The idea
The untyped lambda calculus has three term forms: variables , abstractions , and applications . Nothing stops you from writing or applying a variable to itself, so evaluation can either succeed, or get stuck (e.g. trying to apply a non-function), or loop forever. The Simply-Typed Lambda Calculus restricts the syntax with a type system: every abstraction is annotated with the type of its argument, , and every term is assigned a type by a set of inference rules. A term "type-checks" only if every subterm's type matches how it is used — in particular, only functions of type may be applied to arguments of type . Types in STLC are built from a set of base types (e.g. , or an uninterpreted atom ) and the function type constructor , which is right-associative: means . A typing context (or environment) is a finite partial function from variables to types, written as a list . A typing judgment asserts that, assuming the free variables of have the types recorded in , the term has type . This is exactly the Type Checking and Type Judgments machinery, specialized to a language with only functions. The system is syntax-directed: each term constructor (variable, abstraction, application) has exactly one typing rule whose conclusion has that shape, so a derivation tree mirrors the term's parse tree. This is what makes type checking mechanical rather than a search problem — a crucial difference from full Type Inference and Hindley-Milner systems, where types are not fully annotated and the checker must guess them.
How it works
Syntax. Terms and types are given by the grammars
A value is an abstraction — evaluation stops there; it is already "finished data" in this pure calculus. Reduction is the usual beta reduction (see Beta Reduction), with the standard call-by-value or call-by-name strategy:
together with congruence rules that let reduction happen inside subterms (e.g. reduce the function before the argument, then reduce the argument). Substitution is capture-avoiding, exactly as in the untyped calculus.
Typing rules. Three rules, one per constructor, given as natural-deduction-style inference rules:
Read bottom-up, T-App says: to apply to , the function's domain type must exactly match the argument's type; the result has the function's codomain type. There is no rule that lets you apply an to anything — this is precisely the syntactic restriction that rules out `x x`.
Type checking as an algorithm. Because the rules are syntax-directed and every abstraction carries its argument type, type checking reduces to one recursive function with no guessing or backtracking: ``` function typeOf(Γ, t): match t: case Variable(x): if x in Γ: return Γ[x] else: error "unbound variable x" case Abs(x, τ1, body): Γ' = Γ extended with x : τ1 τ2 = typeOf(Γ', body) return Arrow(τ1, τ2) case App(t1, t2): τ1 = typeOf(Γ, t1) τ2 = typeOf(Γ, t2) match τ1: case Arrow(σ1, σ2): if τ2 == σ1: return σ2 else: error "argument type mismatch: expected σ1, got τ2" case _: error "applying a non-function of type τ1" ``` `typeOf` walks the term once, threading the context downward (extended only at abstractions) and returning a type upward at every node; equality of types (`τ2 == σ1`) is plain structural equality on the type grammar, itself computable by a trivial recursive comparison. The figure below shows the derivation tree for the identity-composition term , matched against the recursive calls of `typeOf`.
Reading the tree bottom-up mirrors exactly the recursive descent of `typeOf`: leaves are `T-Var` lookups, each `App` node combines two recursively-computed types via `T-App`, and each `Abs` node extends , recurses, then wraps the result in an arrow via `T-Abs`. The final judgment at the root, , is the type of "apply twice" — a two-argument-free closed term whose type alone tells you its shape.
Complexity & correctness
Time and space complexity of type checking. `typeOf` visits each node of the term's abstract syntax tree exactly once, doing constant work at variables and applications (a context lookup, or two recursive results plus one structural type-equality test) and constant work at abstractions (extending ). If the term has nodes and types have depth at most (bounded by in the worst case, since a term of size cannot mention a type larger than what its annotations spell out), then:
- Type equality checking costs in the worst case (comparing two type trees).
- Context lookup costs in a naive list-based environment, or / amortized with a hash map or array-backed environment.
- Total time: worst case, which collapses to for the common case where annotated types have bounded size relative to program size (true in virtually all realistic annotated programs). Space is for the recursion stack and the context, since grows only along the path from root to the current node (contexts are restored on backtracking out of an `Abs`, standard in a tree-recursive implementation).
This is exponentially cheaper than inference without annotations, which in richer systems (unification-based Hindley–Milner, see Type Inference and Hindley-Milner) must solve for unknown types rather than just check given ones.
Progress. If , then either is a value, or there exists with . *Proof sketch, by induction on the typing derivation. The T-Var case is vacuous (no closed term is a bare variable, since ). The T-Abs case is immediate: abstractions are values. The interesting case is T-App: with and . By the induction hypothesis, is either a value or steps. If steps, the congruence rule lets step. If is a value, it must — by inspecting the typing rules — be an abstraction , because T-Abs is the only rule that can produce a function type for a value (this is the <strong>canonical forms lemma</strong>: a value of type is necessarily a lambda abstraction). Then either steps (congruence again), or is itself a value, in which case E-AppAbs fires and steps. In every branch is a value or steps — it is never stuck* (an application whose head is not a function).
Preservation (subject reduction). If and , then . *Proof sketch, by induction on the derivation of , using a <strong>substitution lemma</strong>: if and , then (itself proved by induction on the typing derivation of , pushing the substitution through each rule). For the base case E-AppAbs, typed via T-App from and ; inverting T-Abs gives , and the substitution lemma yields exactly as required. Congruence cases follow directly from the induction hypothesis applied to the reduced subterm, rebuilt with the same typing rule. Together, progress and preservation constitute <strong>type safety</strong>: starting from a well-typed closed term, every reduction sequence either terminates in a value or continues stepping, but never* reaches a stuck non-value term. This "well-typed programs don't get stuck" slogan is the exact statement proved; it foreshadows the general treatment in Type Safety and Soundness for languages with many more term forms.
Strong normalization. STLC has a stronger property that progress/preservation alone do not give: every well-typed term, under any reduction strategy, reaches a value in finitely many steps — there are no infinite reduction sequences. This cannot be proved by a naive induction on term structure, because reduction can grow a term (substitution duplicates subterms, e.g. can expand under further substitution). The standard proof method is Tait's method of logical relations: define, by induction on the type (not the term!), a predicate — "reducible at type " — such that means 's reduction sequences are all finite, and means terminates and is reducible at whenever is reducible at . One shows every reducible term terminates, every well-typed term is reducible (by induction on typing derivations), and hence every well-typed term terminates. This is why STLC, despite embedding the shape of general computation, cannot express an infinite loop — the price of the arrow type's discipline is the loss of Turing completeness, which is exactly what makes STLC decidable for type checking and guarantees termination, unlike the untyped calculus or languages with general recursion.
Honesty about tradeoffs. Strong normalization is a blessing and a curse. It rules out non-terminating bugs, but it also rules out legitimate non-terminating programs — an operating system kernel, a web server's event loop, or `while(true)` cannot be written in pure STLC. Real languages add explicit recursion (a `fix` or `letrec` construct, or general recursive functions), which immediately destroys strong normalization while keeping type safety intact; this is precisely the trade made by every practical typed language built on this core.
Worked example
Take and the term
— apply "apply twice" to the identity function on . We trace both the type checker and the evaluator.
| Step | Action | Result |
|---|---|---|
| 1 | `typeOf(∅, λf:ι→ι. λx:ι. f (f x))` | recurse under `f:ι→ι`, then under `x:ι`, get `f(f x) : ι` (as derived in the figure), wrap twice → (ι→ι)→(ι→ι) |
| 2 | `typeOf(∅, λy:ι. y)` | under `y:ι`, `y : ι` by T-Var → ι→ι |
| 3 | T-App check | domain of left term is `ι→ι`; type of right term is `ι→ι` — match |
| 4 | Whole term type | ι→ι (the codomain) |
| 5 | Reduce outer redex | |
| 6 | Reduce inner redex | |
| 7 | Reduce remaining redex | |
| 8 | Final value | , and `typeOf(∅, λx:ι. x) = ι→ι` — matches step 4 |
Step 8 is not a coincidence — it is preservation in action: the type computed once at the start () is exactly the type of the final value after three beta reductions, even though the term's shape changed completely. This is the guarantee that lets a compiler type-check a program once, before running it, and trust that the type remains valid at every intermediate stage of execution — no runtime type tag or check is ever needed. Now contrast with a term that fails to type-check, to see progress's contrapositive at work: . Here `typeOf` computes for the argument, but the function expects type — the T-App rule's premise fails to unify with , so `typeOf` raises "argument type mismatch" and the term is rejected before it is ever run. Untyped, this term would reduce fine to ; typed, STLC refuses it purely on structural grounds, illustrating that the type system is a conservative, sound-but-incomplete filter — it rejects some terms that would actually behave fine, in exchange for rejecting all terms that would get stuck.
Common pitfalls & traps
| Pitfall | Why it happens | Fix |
|---|---|---|
| Forgetting to extend under a binder | Treating `Abs` like a leaf instead of recursing with | Always thread the extended context into the recursive call on the body |
| Confusing type equality with reduction | Assuming "reduces" like a term | Types are checked by structural equality only; there is no notion of type-level computation in plain STLC |
| Variable capture during substitution | Reusing a bound name that collides with a free variable in the substituted term | Use capture-avoiding substitution (alpha-rename as needed), exactly as in untyped Beta Reduction |
| Assuming type checking implies termination in extended languages | STLC's strong-normalization proof relies critically on having no general recursion | Any `fix`/`letrec` addition breaks the logical-relations argument; safety (progress+preservation) can still hold, normalization cannot |
| Believing "well-typed" means "correct" | Type safety only rules out getting stuck, not producing the wrong answer | Type systems are a syntactic sieve, not a full correctness proof; logical bugs can still type-check |
Pioneers & history
The typed lambda calculus arose from efforts to avoid the logical paradoxes (like Russell's paradox and Curry's paradox) that plague untyped systems of self-application, tying type theory's origins directly to foundational mathematics rather than to programming.
Origins · 1 minAlonzo Church and how this was discovered
Church's 1940 paper A Formulation of the Simple Theory of Types recast his earlier untyped calculus with a hierarchy of types precisely to block the self-referential terms that made the untyped system logically inconsistent as a foundation for mathematics. This typed calculus, largely unchanged, is the STLC taught today.
The connection between types and logic was made exact by the Curry–Howard correspondence: types correspond to propositions, and well-typed terms correspond to proofs — a function type is the proposition " implies ", and constructing a term of that type is constructing a proof. This correspondence is why type systems in proof assistants and type systems in programming languages are, at a deep level, the same mathematical object studied from two directions.
Origins · 1 minhow this was discovered
Where this matters
Every statically typed functional language — ML, Haskell, OCaml, F#, Scala's core, Rust's trait-resolved core — is built by layering features onto STLC's arrow-type discipline: parametric polymorphism turns it into System F, mutable state and effects are added on top, and algebraic data types (see Algebraic Data Types and Pattern Matching) extend the type grammar with sums and products alongside the function arrow. Proof assistants such as Coq, Agda, and Lean are built on dependently typed descendants of STLC, using the Curry–Howard correspondence to represent mathematical proofs as well-typed programs, so that a type checker literally verifies a proof. Compilers use STLC-style progress-and-preservation arguments as the template for proving that an intermediate representation (an IR like GHC's Core, or a bytecode verifier's internal type system, such as the JVM's) is safe to optimize and execute. Type checkers embedded in editors and IDEs (for real-time error squiggles) run essentially the `typeOf` algorithm above, scaled up with richer types, on every keystroke. Even outside functional languages, the discipline of "check the types once, trust it forever" underlies static analyses in Java, C#, and TypeScript, all descendants — in typing-rule spirit if not history — of the arrow-typed calculus first written down to save mathematics from paradox.
Ask the tutor: How does adding a `fix` operator change the progress and preservation proofs, and why do they still go through even though normalization fails?
Why is STLC not Turing-complete if it's based on the Turing-complete untyped lambda calculus?
The type system syntactically forbids self-application terms like , because no type can equal for any (arrow types are structurally "bigger" than their domains). This blocks exactly the terms — like — responsible for unbounded looping in the untyped calculus. The logical-relations proof formalizes this: every well-typed term is provably "reducible," and reducibility implies termination. So typing doesn't just filter buggy programs; it provably removes the specific mechanism (unbounded self-application via fixed points) that made the untyped calculus capable of simulating any Turing machine.
Is type checking in STLC always fast in practice?
Yes — because every abstraction carries an explicit type annotation, `typeOf` never needs to guess or search; it is a single linear pass over the term with a constant-time (or near-constant, with good data structures) operation at each node. This is fundamentally different from unannotated systems like Hindley–Milner, which must solve a system of unification constraints to reconstruct missing types. STLC trades annotation burden (the programmer writes every type) for checking speed and simplicity; later systems relax the annotation requirement at the cost of a more complex inference algorithm.
Does a well-typed STLC program always compute the "right" answer?
No. Type safety (progress + preservation) only guarantees the absence of a specific class of runtime failures — applying non-functions, mismatched operations — not semantic correctness. A term can be perfectly well-typed and still implement the wrong algorithm, for instance always returning the identity function when a caller expected "apply twice." Type checking is a syntactic, structural guarantee; establishing that a program meets its intended specification requires additional techniques such as formal specification, equational reasoning, or testing, layered on top of a type-safe core.
What exactly does "canonical forms" mean and why does the progress proof need it?
The canonical forms lemma states that any value of a given type must have been built by a specific syntactic form: a value of an arrow type must be a lambda abstraction, because T-Abs is the only typing rule whose conclusion produces an arrow type for a term that is also a value. The progress proof needs this to justify that when the head of an application is a fully-reduced value of function type, it can definitely be beta-reduced — there is no other kind of value that could have that type and get "stuck" instead. Without canonical forms, the proof would have a gap: knowing a value has an arrow type wouldn't obviously let you apply E-AppAbs.
How does STLC relate to Curry–Howard and proof assistants?
Under Curry–Howard, an arrow type corresponds to the logical implication " implies ," and a closed, well-typed term of that type is literally a proof of the implication. Function application corresponds to modus ponens. STLC's type checker, run on a term claimed to prove some formula, is therefore also a proof checker. Extending STLC with dependent types (types depending on values) yields systems like Martin-Löf type theory, the basis for Coq, Agda, and Lean, where mathematicians write proofs as programs and the type checker verifies them mechanically.
Can STLC express recursive functions like factorial?
Not directly, and this is by design: strong normalization guarantees every well-typed term terminates, which rules out unbounded recursion outright — you cannot write a general `fix` operator with a well-typed fixed-point combinator in pure STLC. Practical languages add an explicit typed recursion construct (`fix : (τ→τ)→τ` or `letrec`), which lets you define factorial-like functions but sacrifices strong normalization: a well-typed program can now genuinely loop forever, though it still cannot get stuck, since progress and preservation survive the extension unharmed.
What's the difference between type checking and type inference in this context?
Type checking, as done here, assumes every term is already annotated with types (every specifies ) and simply verifies consistency — a linear, deterministic, guess-free traversal. Type inference, as in Type Inference and Hindley-Milner, starts from unannotated or partially annotated terms and must reconstruct the missing types, typically via unification over type variables. STLC's algorithm is the simpler checking problem; Hindley–Milner solves the harder inference problem for a calculus extended with polymorphism, but both rest on the same underlying typing rules.