Symptom

“Well-typed programs don’t go wrong.” You have heard it. You have probably said it. And then:

String s = null;
s.length();   // NullPointerException

That program is well typed and it went wrong. So either the slogan is false or it means something narrower than it sounds. It means something narrower, and the narrowness is precisely specifiable.

The practical version of the confusion: someone claims a type system prevents bugs, someone else points at a runtime crash in a statically typed language, and the argument goes nowhere because nobody has stated what the guarantee actually is. It is two lemmas. Once you have them, you can say exactly which crashes are excluded and which are not, for any given language, and the answer for Java is different from the answer for Rust and different again for Haskell.

Statement

Type soundness is the conjunction of two properties relating the typing judgement to the operational semantics.

Progress. If $\vdash e : \tau$ then either $e$ is a value, or there exists $e’$ with $e \to e’$.

Preservation (subject reduction). If $\vdash e : \tau$ and $e \to e’$, then $\vdash e’ : \tau$.

Together: a well-typed closed term never reaches a stuck state, meaning a non-value with no applicable reduction rule. By induction, if $\vdash e : \tau$ and $e \to^* e’$, preservation gives $\vdash e’ : \tau$ and progress gives that $e’$ is a value or steps again. Soundness is therefore “well-typed terms do not get stuck,” and stuck is a syntactic property of the reduction relation, not an informal notion of going wrong.

Read what each does. Progress says a well-typed term is never stuck now. Preservation says taking a step keeps it well typed, so progress applies again. Neither alone is enough, and their conjunction gives you an invariant that survives arbitrarily many steps.

Milner’s original phrase was “well-typed programs cannot go wrong,” where go wrong had a technical definition: reaching the distinguished error value in his denotational semantics. Everything the slogan lost is in that definition.

Argument

Why both, separately. Consider dropping either.

Without progress, a system could type 1 + true if there is simply no reduction rule for adding a boolean. The term is well typed and stuck. Progress is the lemma that says the typing rules are complete enough: every well-typed non-value has somewhere to go.

Without preservation, a term could start well typed and reduce to garbage. Progress applies only to well-typed terms, so it says nothing after the first step. Preservation is the lemma that says the invariant is maintained, which is what lets you iterate.

Progress is about the present, preservation is about induction over time.

How progress is proved. Induction on the typing derivation. For each rule, show the conclusion is a value or steps. The interesting cases need a canonical forms lemma: if $\vdash v : \tau_1 \to \tau_2$ and $v$ is a value, then $v$ is a lambda abstraction. So in e1 e2, once e1 is a value, it is syntactically a function and the $\beta$ rule applies.

Canonical forms is where the real content sits, and it is the lemma that fails in unsound languages. If null inhabits every reference type, then a value of type String need not be a string literal, so the method-invocation rule has no receiver to dispatch on, and progress fails for exactly that case.

How preservation is proved. Induction on the reduction step. The hard case is $\beta$: show that if $\Gamma, x : \tau_1 \vdash e : \tau_2$ and $\Gamma \vdash v : \tau_1$, then $\Gamma \vdash e[x := v] : \tau_2$. That is the substitution lemma, and it in turn needs weakening and exchange on contexts. Most of the length of a real soundness proof is these structural lemmas.

Java’s null is a documented unsoundness, and it is instructive that it was deliberate. Tony Hoare called it his billion-dollar mistake. The fix is well understood and is what modern languages do: Kotlin’s String? versus String, Swift’s optionals, Rust’s Option<T>, and Haskell’s Maybe all restore canonical forms by making absence a separate type. Progress then holds again, because a value of type String really is a string.

Java’s covariant arrays are the other one, and this one is caught at runtime by design:

Object[] objs = new String[1];
objs[0] = Integer.valueOf(42);   // ArrayStoreException

Both lines typecheck. Preservation fails, because the store rule produces a heap that does not match the static type. Java patches it with a runtime check on every array store, which is a soundness hole closed by paying a cost on every array write forever. C# has the same hole for the same reason, and generics in both languages were later designed with variance annotations to avoid repeating it.

A worked failure trace. Take objs[0] = Integer.valueOf(42) and suppose Java did not insert the check. The static type of objs is Object[], so the store is well typed. After the step, the heap contains an array whose runtime type is String[] holding an Integer. Now String s = ((String[]) something)[0] reads it and hands an Integer to code expecting a String. The term was well typed, took a step, and is now not well typed with respect to the heap. That is preservation failing, and the ArrayStoreException is the runtime enforcing by brute force what the type system did not.

What soundness does not include. Progress says the term steps. It does not say it steps to a value, only that it is not stuck. An infinite loop satisfies progress forever, and is entirely sound. Termination is a separate property (strong normalization) that requires giving up Turing completeness, which is why Coq and Agda have it and Haskell does not (T042).

Forbids

Stuck states in a sound language. No 1 + true, no calling an integer, no reading a field from a function. These are eliminated statically, and the absence of the corresponding runtime check is the performance benefit of static typing.

Preserving soundness with unchecked covariant mutable containers. The combination of subtyping, mutation, and covariance is unsound, and the only options are a runtime check (Java arrays), a variance restriction (C# and Scala generics), or accepting the hole.

Type-based optimization in an unsound language. A compiler that trusts types it cannot rely on generates wrong code. This is why unsafe blocks in Rust are a proof obligation: the compiler optimizes assuming aliasing rules hold, and if your unsafe code violates them the miscompilation is your fault by prior agreement.

Soundness surviving an unrestricted cast. Every escape hatch, from C’s casts to Haskell’s unsafeCoerce, voids the theorem for any program using it.

Does not forbid

It does not forbid runtime errors. This is the misreading behind every “but my typed program still crashed.” Division by zero, array bounds violations, stack overflow, and out-of-memory are all sound: the language defines a step for them, to an exception, so the term is not stuck. Rust panics, Java throws, and Haskell’s head [] errors are all within the theorem, because raising a defined exception is a legitimate transition and not a stuck state.

It does not mean typed languages need no tests. Types prove the propositions that are the types (T042). sort :: [Int] -> [Int] proves the result is a list of integers and says nothing about ordering. QuickCheck and property testing exist for exactly the gap, and the strongest type systems narrow it without closing it.

It does not mean unsound systems are useless. TypeScript is deliberately unsound — bivariant method parameters, any, unchecked index access — and it is one of the most successful type systems ever deployed, because it was designed to type existing JavaScript rather than to prove theorems. Soundness is one design goal traded against others, and the tradeoff can be made correctly in either direction.

It does not require proving it by hand. Soundness proofs are mechanized now: CompCert, the RustBelt project for Rust, and the POPLmark-descended infrastructure in Coq and Agda carry them. RustBelt in particular proved soundness for a realistic Rust subset including the unsafe code in the standard library, which is the interesting part.

It does not say the two lemmas hold only for functional languages. The method is generic. It has been applied to Java, JavaScript, WebAssembly, and Rust. WebAssembly shipped with a mechanized soundness proof as part of its specification, which is the first time a mainstream deployed language did.

Boundary

  • Closed terms. The statement is about programs with no free variables. Open terms need the context-indexed version.
  • A fixed semantics. Soundness is relative to these reduction rules. Add a feature and you must reprove, which is why language extensions so often introduce holes.
  • Sequential. Concurrency needs a different formulation; data-race freedom is a separate theorem, and Rust’s is the notable industrial case.
  • No unsafe escape. FFI, reflection, and casts void it locally, and a language’s real guarantee is the theorem plus the size of its escape hatches.
  • Says nothing about termination, complexity, or memory use. A sound program can loop forever, allocate unboundedly, and be quadratic where you expected linear.

The precise version of the slogan: well-typed programs do not get stuck, where stuck means no rule applies. Everything a language chooses to define a rule for, including throwing, is not a violation. If you want to know what a type system guarantees, ask which errors it makes stuck and which it makes steps.