Type Checking and Type Judgments

Consider a compiler faced with the expression `if x then 3 else "no"`. A human immediately senses something is wrong. But a compiler needs a precise, mechanical procedure to reject it. This procedure must generalize to millions of lines of code it has never seen. This is the job of a type checker: an algorithm that decides, once and for all, whether a program obeys the typing discipline of its language before a single instruction runs. Getting this right is what separates a language that catches entire classes of bugs at compile time from one that discovers them as runtime crashes in production. The engineering challenge is not just "detect type errors" but doing so compositionally. The type of a large expression must be computable from the types of its parts. This uses only local information plus a record of what has been assumed about free variables. This local record is the typing context. The local claim being checked or derived is the typing judgment. Together they give type checking the same flavor as parsing or logical proof search — a symbolic derivation built from a fixed set of syntax-directed rules. This machinery matters far beyond textbook calculi. Every production compiler — javac, rustc, GHC, the TypeScript checker, tsc — implements some variant of judgment-based type checking. This is often extended with inference, subtyping, and polymorphism. The judgment notation itself, Γ⊢e:T\Gamma \vdash e : T, is the lingua franca connecting compiler implementations to their specifications. It lets engineers and researchers prove properties like type safety rather than merely test for them. This article gives the precise anatomy of a typing judgment. It shows how typing rules compose into a derivation tree. It walks through the algorithm a real checker runs — including how it handles contexts, function types, and errors. This comes with full pseudocode, a worked derivation, and a discussion of why the process terminates and agrees with the language's intended semantics.

Key takeaways
  • A typing judgment Γ⊢e:T\Gamma \vdash e : T asserts "under assumptions Γ\Gamma, expression ee has type TT."
  • Typing rules are inference rules; stacking them yields a derivation tree proving a judgment.
  • Type checking is syntax-directed: one rule applies per expression form, so checking is a structural recursion over the AST.
  • The context Γ\Gamma tracks variable-to-type bindings and grows only when entering a binder (e.g., a lambda).
  • A sound type system guarantees "well-typed programs don't get stuck" — the bridge to Type Safety and Soundness.
  • The same judgment framework scales, with extra rules, to full languages like The Simply-Typed Lambda Calculus.
The core insight

Type checking is proof search: a program is well-typed exactly when its typing judgment has a derivation built from the language's inference rules.

The idea

A typing judgment is a formal statement of the shape

Γ⊢e:T\Gamma \vdash e : T

read "in context Γ\Gamma, the expression ee has type TT." Here Γ\Gamma (capital gamma) is the typing context (also called a type environment): a finite sequence or set of bindings x1:T1,x2:T2,…,xn:Tnx_1 : T_1, x_2 : T_2, \ldots, x_n : T_n recording the assumed type of every free variable currently in scope. The judgment is not code that runs — it is a claim, and the type checker's job is to decide whether that claim is provable. Judgments are proved using typing rules, written as inference rules in the standard logical style:

premise1premise2⋯conclusion (Rule-Name)\frac{\text{premise}_1 \quad \text{premise}_2 \quad \cdots}{\text{conclusion}}\ (\text{Rule-Name})

Each rule says: if you already have derivations of the premises (judgments above the line), you may conclude the judgment below the line. A rule with no premises is called an axiom. For example, in a simple typed language with booleans, integers, variables, and functions, typical rules include:

Γ⊢true:Bool (T-True)Γ⊢n:Int (T-Int)\frac{}{\Gamma \vdash \texttt{true} : \texttt{Bool}}\ (\text{T-True}) \qquad \frac{}{\Gamma \vdash n : \texttt{Int}}\ (\text{T-Int})
x:T∈ΓΓ⊢x:T (T-Var)\frac{x : T \in \Gamma}{\Gamma \vdash x : T}\ (\text{T-Var})
Γ,x:T1⊢e:T2Γ⊢λx:T1. e:T1→T2 (T-Abs)\frac{\Gamma, x : T_1 \vdash e : T_2}{\Gamma \vdash \lambda x{:}T_1.\,e : T_1 \to T_2}\ (\text{T-Abs})
Γ⊢e1:T1→T2Γ⊢e2:T1Γ⊢e1 e2:T2 (T-App)\frac{\Gamma \vdash e_1 : T_1 \to T_2 \qquad \Gamma \vdash e_2 : T_1}{\Gamma \vdash e_1\ e_2 : T_2}\ (\text{T-App})
Γ⊢e1:BoolΓ⊢e2:TΓ⊢e3:TΓ⊢if e1 then e2 else e3:T (T-If)\frac{\Gamma \vdash e_1 : \texttt{Bool} \qquad \Gamma \vdash e_2 : T \qquad \Gamma \vdash e_3 : T}{\Gamma \vdash \texttt{if}\ e_1\ \texttt{then}\ e_2\ \texttt{else}\ e_3 : T}\ (\text{T-If})

The notation Γ,x:T1\Gamma, x : T_1 means "Γ\Gamma extended with the new binding x:T1x : T_1"; this is how contexts grow when a rule descends into the body of a binder like a lambda. Crucially, each rule's premises mention strictly smaller subexpressions than its conclusion — this is what makes the rules syntax-directed: for any expression form, at most one rule's conclusion pattern matches, so checking proceeds by structural recursion on the abstract syntax tree with no guessing. A full proof that Γ⊢e:T\Gamma \vdash e : T holds is a tree — a derivation tree or type derivation — whose leaves are axioms and whose internal steps apply rules to already-derived judgments. Type checking is the algorithmic search for such a tree; type checking succeeds exactly when the tree exists, and the checker either constructs it (implicitly, via recursive calls) or reports precisely where no rule applies. This connects tightly to What Is a Type System? as the formal skeleton behind the informal notion of "a system of typing rules," and it explains why type checking behaves differently from the value computation studied under Static vs Dynamic Typing — it reasons about types alone, never running the program.

How it works

Concretely, a type checker is a recursive function `typeOf(Γ, e)` that either returns the unique type TT such that Γ⊢e:T\Gamma \vdash e : T, or signals failure. Because the rules are syntax-directed, the function dispatches on the shape of `e`, mirrors each rule literally, and recurses on subexpressions — exactly like a recursive-descent evaluator, except it manipulates types instead of values. There are two closely related tasks that people sometimes conflate:

  • Type checking (bidirectional "checking" mode): given `Γ`, `e`, and a candidate type `T`, decide whether `Γ ⊢ e : T`.
  • Type synthesis / inference (the "synthesizing" mode used below): given `Γ` and `e`, compute the unique `T` with `Γ ⊢ e : T`, or fail.

For the simply-typed core shown above (with explicit lambda annotations), synthesis is fully determined — every construct has exactly one applicable rule, and it either produces a type or fails. This is the design goal: no backtracking, no search over rules — the syntax of `e` alone selects the rule. ```

# typeOf(Γ, e): compute the type of e under context Γ, or raise TypeError function typeOf(Γ, e): match e: case True: return Bool # (T-True) case False: return Bool # (T-True variant) case IntLit(n): return Int # (T-Int) case Var(x): if x in dom(Γ): # (T-Var) return Γ[x] else: raise TypeError("unbound variable " + x) case Lambda(x, T1, body): Γ' = Γ.extend(x, T1) # Γ, x:T1 T2 = typeOf(Γ', body) # (T-Abs) return Arrow(T1, T2) case App(e1, e2): Tf = typeOf(Γ, e1) # (T-App) Ta = typeOf(Γ, e2) if Tf is Arrow(T1, T2) and T1 == Ta: return T2 else: raise TypeError("bad application: expected " + Tf.domain + " got " + Ta) case If(e1, e2, e3): T1 = typeOf(Γ, e1) # (T-If) if T1 != Bool: raise TypeError("condition not Bool") T2 = typeOf(Γ, e2) T3 = typeOf(Γ, e3) if T2 != T3: raise TypeError("branches disagree: " + T2 + " vs " + T3) return T2 default: raise TypeError("unknown expression form") ``` Each `case` in `typeOf` is a direct transcription of one inference rule: the recursive calls are the premises, the returned type (or the equality checks that must hold first) is the conclusion. Function `dom(Γ)` returns the set of variables bound in `Γ`, and `Γ.extend(x, T1)` returns a new context with `x : T1` prepended — the standard implementation is a list (a "cons" structure), so lookup and extension are both O(1)O(1) to perform, with lookup taking O(∣Γ∣)O(|\Gamma|) time to scan. The rule (T-Var) deserves special note: it is the only rule that reads from Γ\Gamma, and it is what makes the context necessary at all. Without a running record of bound variables' types, the checker would have no way to assign a type to `x` once it appears freely inside a lambda body.

Figure
A typing derivation as a proof tree: each node is a judgment, each edge-group a rule application
x:Bool, y:Int ⊢ x : Bool (T-Var) x:Bool, y:Int ⊢ y : Int (T-Var) x:Bool, y:Int ⊢ 0 : Int (T-Int) x:Bool, y:Int ⊢ if x then y else 0 : Int (T-If) x:Bool ⊢ λy:Int. if x then y else 0 : Int → Int (T-Abs, binds y) ⊢ λx:Bool. λy:Int. if x then y else 0 : Bool → Int → Int (T-Abs, binds x) Derivation tree (built bottom-up by typeOf, read top-down as a proof) Leaves: axioms / T-Var lookups. Each bar = one rule application discharging its premises.

Growing the context. The only place Γ\Gamma changes is inside `T-Abs`: entering the body of `λx:T1. e` requires assuming `x : T1`, so the recursive call uses `Γ' = Γ, x:T1`, not `Γ`. When that recursive call returns (or the checker "pops back out" of the lambda), the extension is discarded — bindings are scoped exactly like variable scope in the source language. This context discipline is why shadowing works correctly: if `x` is already bound in `Γ`, extending it with a fresh `x:T1` simply makes the new binding the one found by the nearest lookup, matching lexical scoping.

Error reporting. A production checker does not just say "TypeError" — it reports the expected type, the found type, and the source location, because the whole point of static typing is to give the programmer actionable feedback before running anything.

Complexity & correctness

Time complexity. For a language without subtyping or polymorphism (like the fragment above), `typeOf` visits every AST node exactly once, doing O(1)O(1) work per node aside from context lookup and type equality checks:

  • Context lookup, `T-Var`, costs O(d)O(d) where dd is the nesting depth (number of enclosing binders) — bounded by the size of the program, so O(n)O(n) in the worst case per lookup with a naive list-based context.
  • Type equality checks (`T1 == Ta`, `T2 == T3`) compare two type expressions structurally; for simple types this is O(s)O(s) where ss is the size of the type (linear-size types make this effectively O(1)O(1) in practice, but pathological type-level constructs, e.g. large records or recursive type synonyms, can make this more expensive).

Summing over all nn AST nodes gives overall time

O(n⋅dmax⁡)O(n \cdot d_{\max})

in the naive implementation, where dmax⁡d_{\max} is the maximum variable-lookup depth. In practice, this is reduced to amortized O(n)O(n) by implementing contexts as hash maps (symbol tables) rather than lists, making `T-Var` lookup O(1)O(1) expected time; this is exactly what real compilers do. So: type checking a simply-typed program is linear in program size, matching the intuition that it should cost about as much as one more traversal of the AST — comparable to a parsing or scope-resolution pass.

Space complexity. The context Γ\Gamma at any point has size proportional to the current nesting depth, so it needs O(dmax⁡)O(d_{\max}) space on the call stack at the deepest point of recursion; total space (including the AST itself) is O(n)O(n).

Why this terminates. The recursion in `typeOf` always calls itself on a strictly smaller subexpression (a child in the AST), and the AST is finite, so by well-founded induction on expression size the recursion always terminates — there is no risk of infinite loops, unlike general program execution. This is a key structural difference from evaluating the program (which may loop forever); type checking a terminating-or-not program is guaranteed to itself terminate, given a decidable base type-equality check.

Why this is correct (soundness of the checker relative to the rules). "Correctness" here means: `typeOf(Γ, e)` returns `T` if and only if the judgment Γ⊢e:T\Gamma \vdash e : T has a derivation using the inference rules. This is proved by structural induction on `e`:

  • Base cases (`True`, `False`, `IntLit`, `Var`): the function's behavior is a direct transcription of the corresponding axiom or lookup rule; it returns `T` exactly when that rule's conclusion is derivable.
  • Inductive cases (`Lambda`, `App`, `If`): by the induction hypothesis, the recursive calls correctly compute the types of the (strictly smaller) subexpressions if and only if those subjudgments are derivable. The function then checks exactly the side conditions the corresponding rule demands (matching argument types, matching branch types, `Bool` condition) before assembling the conclusion — so the top-level call succeeds if and only if all premises of the relevant rule are derivable, which by definition of a derivation tree is if and only if the whole judgment is derivable.

Because each syntax form maps to exactly one rule (the rules' conclusions have disjoint syntactic shapes), the algorithm never needs to guess or backtrack: this determinism is what makes type synthesis in this fragment a total, decidable function rather than an approximate search. This same argument — that a syntax-directed algorithm faithfully mirrors a set of inference rules — is exactly the technique later used to establish Type Safety and Soundness for richer calculi such as The Simply-Typed Lambda Calculus, where the added theorems (progress and preservation) build on precisely this notion of a derivable judgment.

Honest tradeoffs. This clean picture depends on annotated binders (`λx:T1. e` states `T1` explicitly). Once a language drops annotations and expects type inference (e.g., Hindley–Milner-style `let`-polymorphism), the checker must solve unification constraints rather than merely dispatch syntactically, and complexity results become subtler (Hindley–Milner inference is efficient in practice but has been shown exponential in adversarial cases due to let-polymorphism's interaction with sharing). Adding subtyping breaks the "exactly one applicable rule" property and typically requires an explicit subsumption rule plus a separate subtype-checking algorithm, trading simplicity for expressiveness.

Worked example

Take the language above and check the expression

e  =  λx:Bool. λy:Int. if x then y else 0e \;=\; \lambda x{:}\texttt{Bool}.\ \lambda y{:}\texttt{Int}.\ \texttt{if}\ x\ \texttt{then}\ y\ \texttt{else}\ 0

starting from the empty context Γ0=∅\Gamma_0 = \varnothing. We trace `typeOf(∅, e)` call by call.

StepCallContext usedRuleResult
1`typeOf(∅, λx:Bool. λy:Int. if x then y else 0)`∅\varnothingT-Abs (binds `x`)needs type of body under x:Boolx{:}\texttt{Bool}
2`typeOf({x:Bool}, λy:Int. if x then y else 0)`x:Boolx{:}\texttt{Bool}T-Abs (binds `y`)needs type of body under x:Bool,y:Intx{:}\texttt{Bool}, y{:}\texttt{Int}
3`typeOf({x:Bool, y:Int}, if x then y else 0)`x:Bool,y:Intx{:}\texttt{Bool}, y{:}\texttt{Int}T-Ifneeds types of `x`, `y`, `0`
4`typeOf({x:Bool, y:Int}, x)`sameT-Varreturns `Bool` — condition is `Bool` ✓
5`typeOf({x:Bool, y:Int}, y)`sameT-Varreturns `Int`
6`typeOf({x:Bool, y:Int}, 0)`sameT-Intreturns `Int`
7back in step 3—T-If checkT2=Int=T3T_2 = \texttt{Int} = T_3 ✓ → returns `Int`
8back in step 2—T-Absbody type `Int`, so returns Int→Int\texttt{Int} \to \texttt{Int}... wait, binder is `y:Int`
8′back in step 2—T-Absreturns Int→Int\texttt{Int} \to \texttt{Int} combined with `y:Int`: Int→Int\texttt{Int} \to \texttt{Int}
9back in step 1—T-Absreturns Bool→(Int→Int)\texttt{Bool} \to (\texttt{Int} \to \texttt{Int})

The final answer is

∅⊢λx:Bool. λy:Int. if x then y else 0  :  Bool→Int→Int.\varnothing \vdash \lambda x{:}\texttt{Bool}.\,\lambda y{:}\texttt{Int}.\,\texttt{if}\ x\ \texttt{then}\ y\ \texttt{else}\ 0 \;:\; \texttt{Bool} \to \texttt{Int} \to \texttt{Int}.

This matches the derivation tree drawn above, read bottom-to-top as "what must already be true" and top-to-bottom as "what the checker returns as it unwinds the recursion."

A second trace, showing a rejection. Now check e′=λx:Bool. if x then 3 else falsee' = \lambda x{:}\texttt{Bool}.\ \texttt{if}\ x\ \texttt{then}\ 3\ \texttt{else}\ \texttt{false}. 1. `typeOf(∅, e')` invokes T-Abs, recursing into `typeOf({x:Bool}, if x then 3 else false)`. 2. T-If needs `x : Bool` (✓, via T-Var), then `typeOf({x:Bool}, 3) = Int` and `typeOf({x:Bool}, false) = Bool`. 3. T-If's side condition demands T2=T3T_2 = T_3, but `Int ≠ Bool`. 4. `typeOf` raises `TypeError("branches disagree: Int vs Bool")`, and no derivation tree exists for the top-level judgment — the checker correctly reports the program as ill-typed, without ever attempting to execute the branches. This second trace is the important asymmetric case: it shows the checker actively refuting a judgment, not merely confirming an easy well-typed program, and it demonstrates exactly which rule and which side condition fails — the information a real diagnostic message would surface.

Common pitfalls & traps

PitfallWhy it happensFix
Forgetting to extend Γ\Gamma inside a binderCopy-pasting the outer context instead of `Γ, x:T1` before recursing into the bodyAlways create a fresh extended context per binder; never mutate the caller's context in place
Using the wrong context after returning from a binderTreating context extension as a global mutation rather than scoped to the recursive callPass contexts as immutable values (or explicitly pop bindings) so scope ends exactly at the binder's body
Confusing type checking with type equality of valuesAssuming `if` branches must literally be the same expression rather than the same typeCompare only the types T2,T3T_2, T_3, never expression syntax or runtime values
Missing the "no rule applies" caseOnly handling known constructs and forgetting a `default` branchAlways include an explicit failure case for unrecognized syntax, with a clear diagnostic
Assuming context lookup is O(1)O(1) with a naive listTesting only on tiny programs where depth is negligibleUse a hash-based symbol table for `Γ` to guarantee expected O(1)O(1) lookup on deep programs
Conflating "well-typed" with "does what the programmer meant"Type checking only rules out type errors, not logic errorsRemember: a passing type check is necessary, not sufficient, for program correctness

Pioneers & history

Origins · 1 minAlonzo Church and how this was discovered
AC
Alonzo Church
1903–1995 · Mathematical logic, Princeton University

Church invented the lambda calculus and, in 1940, the simply typed lambda calculus — attaching a type to every term specifically to block the paradoxes that untyped self-application allowed. His formulation already had the shape of a typing judgment relating a term to a type under assumptions, the direct ancestor of Γ⊢e:T\Gamma \vdash e : T used by every modern type checker.

History
1902
— Russell's paradox exposes the need for a stratified, typed foundation for logic.
1940
— Church publishes the simply typed lambda calculus, formalizing types as a discipline on terms.
1958
— Curry and Feys develop combinatory logic with types, deepening the proofs-as-programs connection.
1969
— de Bruijn's Automath and Curry–Howard correspondence link typing derivations to logical proofs.
1978
— Robin Milner's "A Theory of Type Polymorphism in Programming" gives Hindley–Milner inference, powering ML-family languages.

The specific Γ⊢e:T\Gamma \vdash e : T notation and the systematic "inference rule" presentation used throughout modern compiler courses and papers was popularized through the natural-deduction style of proof theory (Gentzen) applied to typing by later logicians and language designers, and it became the standard teaching and specification vehicle for programming-language type systems in the late twentieth century — the same style used to state the progress and preservation theorems central to Type Safety and Soundness.

Where this matters

Every statically typed production compiler runs some descendant of the `typeOf` algorithm above. javac and rustc perform syntax-directed type checking (extended with generics, subtyping, and trait/overload resolution) as a dedicated compiler phase before code generation, catching mismatched arguments and incompatible branches exactly as in the worked example. The TypeScript compiler (tsc) layers a structural type checker over JavaScript's untyped runtime, using contexts to track variable types across nested closures and reporting the precise rule and location that fails, mirroring the diagnostic style shown above. GHC (Haskell) implements a far richer version of this framework — Hindley–Milner inference with type classes — where the context Γ\Gamma also carries class constraints, but the core judgment-and-derivation skeleton is unchanged. Proof assistants such as Coq and Agda take the idea to its logical endpoint: via the Curry–Howard correspondence, a typing derivation is a mathematical proof, so their type checkers double as proof checkers. Finally, IDE tooling (autocomplete, "type at cursor," inline error squiggles) is typically the type checker's `typeOf` function invoked interactively on partial or edited ASTs, which is why understanding the recursive, context-threading structure described here explains both compiler errors and everyday editor feedback.

Ask the tutor: Can you show how this typing-rule framework needs to change once we add a `let`-binding with polymorphism, as in Hindley–Milner type inference?

What is the difference between a typing rule and a typing judgment?

A judgment is a single claim, like Γ⊢e:T\Gamma \vdash e : T, asserting a specific expression has a specific type under specific assumptions. A rule is a template that produces new judgments from existing ones — the "if these premises hold, then this conclusion holds" schema. You can think of judgments as sentences and rules as the grammar of valid proofs connecting them. A full type derivation is a tree of judgments where every step is licensed by some rule; the type checker's job is to search for, or algorithmically construct, that tree for a given program.

Why does the context Γ need to exist at all — why not just check e in isolation?

Expressions frequently contain free variables whose types are only known because of an enclosing binder, such as a function parameter or a `let`. Without a context, the checker would have no way to look up what type `x` has when it encounters a bare reference to `x` deep inside a function body. The context is exactly the accumulated record of "what has been assumed so far," analogous to a symbol table in a compiler's scope-resolution pass, and it is threaded through the recursive type-checking calls precisely following the program's lexical scoping.

Is type checking the same as type inference?

Not quite. Type checking (in "checking mode") verifies a given candidate type against an expression, while type inference (or synthesis) computes a type from scratch, sometimes solving unification constraints when parts of the program are unannotated. In the simply-typed fragment shown here, with fully annotated lambdas, synthesis is straightforward and deterministic. Once you allow unannotated functions and let-polymorphism, as in Hindley–Milner systems, inference becomes a constraint-solving problem, though checking remains comparatively simple once a type is proposed.

Does a passing type check guarantee the program is correct?

No — it guarantees only the absence of type errors, such as calling a function with the wrong argument type or branching on a non-boolean condition. A well-typed program can still have logic bugs, infinite loops, or wrong outputs; type checking is a necessary filter, not a full correctness proof. What a sound type system does guarantee, formally, is captured by the progress and preservation theorems central to Type Safety and Soundness: well-typed programs never get "stuck" in an undefined operation.

Why do type checkers reject perfectly reasonable-looking programs sometimes?

Because typing rules are deliberately conservative: they only accept a program if a derivation tree can be mechanically constructed using the fixed set of rules, not if the program merely happens to behave correctly at runtime for the inputs you care about. For instance, `if cond then 3 else "x"` might never actually evaluate the "wrong" branch at runtime, but the type checker has no way to know that without executing the program, so the T-If rule's side condition — matching branch types — conservatively rejects it. This tradeoff, rejecting some safe programs to guarantee no unsafe ones slip through, is fundamental to static typing.

How does this relate to the simply typed lambda calculus mentioned as a next topic?

The rules and algorithm shown here — T-Var, T-Abs, T-App, T-If, plus base types — essentially are a fragment of the simply typed lambda calculus (STLC) extended with booleans and integers as a teaching example. The Simply-Typed Lambda Calculus studies this same judgment framework in its minimal, canonical form (just variables, abstraction, and application over base types), which is the setting where the classic type safety theorems are first proved rigorously and where the connection to logic via Curry–Howard is cleanest.

What happens when languages add features like subtyping or generics — does the judgment notation still work?

Yes, but with additions. Subtyping typically adds a separate judgment Γ⊢T1<:T2\Gamma \vdash T_1 <: T_2 (a subtype judgment) plus a subsumption rule letting an expression of type T1T_1 be used where T2T_2 is expected. Generics add type variables and often a second context tracking type-variable bindings, alongside constraints. In every case, the core discipline is preserved: judgments state claims, rules license derivations, and the checker's algorithm is a structural traversal that tries to build (or refute) a derivation — the machinery scales rather than being replaced.

Before this

Where this leads