Soundness & the Unsound Corners
Expert · 22 min read · ▶ live playground · ✦ checkpoint
Soundness means this promise: if the checker accepts your program, the checked type facts cannot be false at runtime. TypeScript is not fully sound, on purpose, because it must describe practical JavaScript code instead of forcing JavaScript into a smaller language.
That sentence should not make you trust TypeScript less. It should make you trust it more honestly. The checker is excellent at local evidence, assignability, narrowing, and API contracts. It also has a few deliberate holes where usefulness beat a perfect proof.
Method bivariance
Bivariance means a function parameter position is accepted in both assignment directions. In strict TypeScript, plain function-typed properties are checked more carefully, but methods keep a bivariant parameter rule for compatibility.
This read-only example type-checks cleanly, then crashes:
type TextChange = { value: string };
type NumberChange = { value: number };
type ChangeHandler = {
handle(event: TextChange | NumberChange): void;
};
const textOnly: { handle(event: TextChange): void } = {
handle(event) {
console.log(event.value.toUpperCase());
},
};
const handler: ChangeHandler = textOnly;
handler.handle({ value: 42 });→ TypeError: event.value.toUpperCase is not a function
The assignment is allowed because handle(event) { ... } is a method. The runtime call is legal JavaScript too, and it passes a number-shaped event into code that only knew how to handle text.
strictFunctionTypes fixes the property-shaped version of this mistake. It checks function parameters in the safer, opposite direction for function-typed properties. It does not change method bivariance, and it does not touch the other holes in this lesson.
The safer pattern is not magic. It is the same Section 4 narrowing habit: accept the full promised input, inspect the value, then use the narrowed branch.
Mutable array covariance
Covariance means a wrapper moves in the same assignability direction as its item type. Dog is assignable to Animal, so TypeScript allows Dog[] where Animal[] is expected. That is convenient for reading, but mutable arrays also allow writing.
type Animal = { name: string };
type Dog = { name: string; barkVolume: number };
const dogs: Dog[] = [{ name: "Miso", barkVolume: 4 }];
const animals: Animal[] = dogs;
animals.push({ name: "Whiskers" });
console.log(dogs[1].barkVolume.toFixed(0));→ TypeError: Cannot read properties of undefined (reading 'toFixed')
The type proof said dogs[1] is a Dog. The runtime array now contains an animal with no barkVolume, because animals and dogs are two names for the same mutable array.
any and assertions
any is the checker off switch from Section 2.4. Once a value is any, it can be assigned to a precise type without proof.
type VisitStats = {
name: string;
visits: number;
};
const unchecked: any = JSON.parse('{"name":"Ada","visits":"many"}');
const stats: VisitStats = unchecked;
console.log(stats.visits.toFixed(0));→ TypeError: stats.visits.toFixed is not a function
An assertion is the as SomeType syntax that tells the checker to believe you. It is not validation, conversion, or a runtime check.
type VisitStats = {
name: string;
visits: number;
};
const raw: unknown = { name: "Ada", visits: "many" };
const stats = raw as VisitStats;
console.log(stats.visits.toFixed(0));→ TypeError: stats.visits.toFixed is not a function
Living with the holes
The conventions are the ones you have practiced all course. Use unknown at boundaries. Parse unknown data with schemas, decoders, or predicates before it enters app code. Prefer readonly arrays for inputs you only read. Keep assertions narrow, local, and attached to a check that just ran. Let typed lint, type tests, runtime tests, and CI catch policy drift.
Notice the shape of the safe code. The boundary starts as unknown, not any. The type guard returns a receipt before visits.toFixed is called. The dog list is readonly because the function only needs to read it. This is how professional TypeScript uses an unsound language safely: keep the holes small, named, and reviewed.
Next lesson looks forward to TypeScript's native future and the editor relationship around the checker. The language bargain stays the same; the toolchain story gets faster and more sustainable.
Checkpoint
Answer all three to mark this lesson complete