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.

typescript — playgroundtype-checked
⌘/Ctrl + Enter to run

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.

typescript — playgroundtype-checked
⌘/Ctrl + Enter to run

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

+50 XP on completion