Command Palette

Search for a command to run...

Hectal
PHASE 16Advanced ~22 min· topic 5 of 6

Topic 16.5

Soundness Holes and How to Stay Safe

In one line

TypeScript is deliberately not a fully sound type system: a few rules let wrong programs through so that real JavaScript stays easy to type. Each hole here is shown with the runtime crash it allows, and with the habit or lint rule that closes it.

Think of it like this

A smoke alarm in a kitchen that never goes off when you toast bread is a sound enough alarm: it is quiet most of the time, and it rings for a real fire. A perfectly sound alarm would ring for toast every morning and you would pull out the battery. TypeScript's designers made the same trade: a perfectly sound checker would reject a lot of everyday JavaScript, so a few unsafe-but-convenient rules are allowed. This topic lists them so you know where the alarm stays quiet.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Sound
A value that passes the checker can never break its type at runtime. TypeScript is not fully sound.
Covariant
Accepts a narrower type where a wider one is expected, like Dog[] where Animal[] is expected.
Contravariant
Accepts a wider type where a narrower one is expected. Function parameters are the usual case.
Bivariant
Accepts both directions. Loose and unsafe, but convenient. Method parameters are bivariant.
strictFunctionTypes
A strict flag: parameters of function-type properties are checked contravariantly. Methods are exempt.
any leak
An any entering your program from a library call or a cast and silently spreading to everything it touches.
noUncheckedIndexedAccess
A flag that adds undefined to the type of an array element or index-signature read.
Unsound on purpose
A hole the TypeScript team accepted so that common JavaScript patterns type-check.

Step by step

01The promise a Dog[] makes

A Dog[] promises that every element is a Dog. Assigning it to an Animal[] is allowed, because reading from it is safe (each Dog is an Animal). But writing is not: the Animal[] view lets you push a Cat, and the original array still says Dog.

The checker only looks at the direction of reading. This is array covariance, and it is the oldest hole.

02Methods are checked loosely

Under strictFunctionTypes, a callback property must accept everything the target promises it will receive. A method is exempt. The narrow function that only handles strings is accepted as the handle method, then called with 42.

Methods are checked looselydiagram
Rendering diagram…

03Where any enters without you typing it

JSON.parse returns any. Anything you read from it is any, and any is assignable to every type. So const age: number = parsed.age passes while age is really undefined. Hover with // ^? to see the any.

04Reading past the end

Without noUncheckedIndexedAccess, names[2] is just string. With it, the checker adds undefined and forces a check. This flag costs a few if statements and removes a common crash.

05Close the holes you can

Use readonly views, unknown at boundaries, property-style callbacks, and local const copies of narrowed values. Add the lint rules so the team gets the same warning in review. The last example shows the safe versions.

Try it yourself

  1. 1

    Close the array hole

    In the first example, change const animals: Animal[] = dogs; to const animals: readonly Animal[] = dogs;. Predict which line becomes the error, and what happens in the Console. The error moves to animals.push(new Cat()) (TS2339, "Property 'push' does not exist on type 'readonly Animal[]'"). The Console still crashes, because types are erased: the checker warned, it did not stop the program.

  2. 2

    Turn a property into a method

    In the second example, change handle: (x: string | number) => void; in WithProp to handle(x: string | number): void;. Predict whether the error on the p line disappears. It does, and the Console still crashes on m.handle(42): the same function is now accepted.

  3. 3

    Copy before you call

    In the narrowing example, rewrite show to read const email = u.email; first and test email. Predict whether the program still crashes. It does not: the Console prints A@X.IO, because the local const keeps its string even after reset(u) removes the property.

Code & diagrams

Array covariance: a Dog[] that holds a Cat

The Types panel is clean: Dog[] is assignable to Animal[] and push(Cat) is fine on an Animal[]. The console shows the result: two elements in dogs, and the second one has no bark. Change the declaration of animals to readonly Animal[] to see the fix.

Console

(nothing was printed)

Expected console output

2
woof
Uncaught TypeError: d.bark is not a function
Method bivariance: a method accepts what a property refuses

The property version is refused (strictFunctionTypes) and the method version, with the same function, is accepted. The call with 42 then crashes inside narrow.

Console

(nothing was printed)

Expected console output

OK
Uncaught TypeError: x.toUpperCase is not a function
any leaks in from JSON.parse

parsed is any, so parsed.age is any and any is accepted as a number. The first log works, the second crashes: age is really undefined.

Console

(nothing was printed)

Expected console output

ASHA
Uncaught TypeError: Cannot read properties of undefined (reading 'toFixed')
Index access with noUncheckedIndexedAccess off

This example turns the flag off (the tsc --init default is on). third is a string, and the crash is found only at runtime. With the flag on, the type is string | undefined and the last line is an error: TS18048 'third' is possibly 'undefined'.

Console

(nothing was printed)

Expected console output

Uncaught TypeError: Cannot read properties of undefined (reading 'toUpperCase')
Narrowing survives a call that changes the value

Inside the if, u.email is a string, and the checker keeps believing it after reset(u). Copy it first (const email = u.email) and the problem disappears.

Console

(nothing was printed)

Expected console output

Uncaught TypeError: Cannot read properties of undefined (reading 'toUpperCase')
Casts: readonly escape hatch and a widened property

The plain assignment of a readonly array to a mutable one is an error, but the as version passes and mutates the supposedly read-only array. Writing a number through the wider view y breaks the original x.

Console

(nothing was printed)

Expected console output

[ 1, 2, 3, 4 ]
Uncaught TypeError: x.a.toUpperCase is not a function
Closing the holes: readonly views and unknown

The readonly view refuses push. unknown refuses every use until the code checks what it holds. After the checks, data.name is allowed and prints.

Console

(nothing was printed)

Expected console output

Asha

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Take away the readonly view

In the last example, change const safe: readonly object[] = dogs; to const safe: object[] = dogs;.

terminal
$ Types panel
── what you'll see ──
index.ts(9,1): error TS18046: 'data' is of type 'unknown'.

Myth vs fact

Myth

If the code compiles with strict on, it cannot hit a type error at runtime.

Fact

The six holes above all compile under strict. Strict mode narrows the gaps (null checks, function parameters), it does not close them.

Myth

any is only dangerous where you write it.

Fact

any returned from JSON.parse, from response.json() or from an untyped library enters your program silently and spreads through everything it touches.

Myth

Methods and function-type properties are the same thing.

Fact

They look alike but are checked differently under strictFunctionTypes: a property is strict, a method is bivariant.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    The unsound rules are chosen, not accidental: array covariance and method bivariance keep everyday JavaScript patterns easy to type (an array of dogs is usable as an array of animals). The official Design Goals say so directly: a sound type system is a non-goal, and the aim is a balance between correctness and productivity. For methods, the TypeScript 2.6 release notes give the reason: methods are excluded from strict function checking "to ensure generic classes and interfaces (such as Array<T>) continue to mostly relate covariantly". A few opt-in flags (strictFunctionTypes, noUncheckedIndexedAccess, exactOptionalPropertyTypes) close part of the gap.

  • ▸

    in and out variance annotations (TypeScript 4.7) let you state a type parameter's variance yourself, and the checker then verifies your own class or interface against it. They are the tool for library authors who want a precise, checked variance (Topic 7.7).

  • ▸

    Closing holes is cheaper in tooling than in review: @typescript-eslint/no-unsafe-* catches any leaks, no-unsafe-type-assertion flags the narrowing-casts, and no-unsafe-declaration-merging flags the class-plus-interface trap from Topic 16.3. They need type-aware linting (Topic 15.2).

Remember this

  1. 1

    The problem this solves: If you believe the checker is a proof, you stop looking where it cannot see. Knowing the exact places where it stays quiet while the program can still crash tells you where to add your own care. (Recall from Topic 7.7: covariance and contravariance describe which direction a type may be widened.) A type system is sound when a value that passes the checker can never break its type at runtime. TypeScript is not sound, on purpose (the TypeScript Design Goals list a sound or "provably correct" type system as a non-goal and say to "strike a balance between correctness and productivity"). The holes are few and well known. The examples below each end with a real crash, shown in the Console, so you can see exactly what the types promised and what happened.

  2. 2

    Hole 1: array covariance. An array of Dog is accepted where an array of Animal is expected, because a Dog is an Animal. But the array can be written to: through the Animal[] view you can push a Cat, and the original Dog[] now holds a cat. The first example crashes with d.bark is not a function. The fix is to take a read-only view: readonly Animal[] has no push, so the unsafe write is a type error.

  3. 3

    Hole 2: method bivariance. A function type written as a property (handle: (x: string | number) => void) is checked strictly under strictFunctionTypes: a function that only accepts string is refused. But a method declaration (handle(x: string | number): void) is exempt and is checked loosely in both directions (bivariant). The same narrow function is accepted there, and m.handle(42) crashes. The reason is history: many built-in types (like Array) are written with methods, and making them strict would have broken a large amount of existing code. The fix is to declare callbacks as properties, and a lint rule (method-signature-style) enforces it.

  4. 4

    **Hole 3: any leaks.** any switches checking off, and it spreads: whatever you read from an any is any again. Standard library functions that return any, such as JSON.parse (and response.json()), put an any into your program without you writing the word. In the example, age: number is accepted, because parsed.age is any; then age.toFixed crashes. The fix is unknown at the boundary and a check or validation before use (Topic 13.4); the lint rules no-explicit-any and the no-unsafe-* family catch the leaks.

  5. 5

    Hole 4: index access. names[2] has the element type, string, even when the array has two items. With noUncheckedIndexedAccess (part of the tsc --init set, so on by default in these examples) the type becomes string | undefined, and the crash is caught at compile time. Turn the flag off and the example compiles and then crashes. The same idea applies to index signatures on objects.

  6. 6

    Hole 5: narrowing is not invalidated by calls. After if (u.email) the checker believes u.email is a string. If you then call a function that changes u (here, reset(u) deletes the email), the checker does not know, and the next line crashes. The checker assumes function calls do not change the properties you narrowed. Keep narrowed values in a const local: const email = u.email; if (email) { reset(u); email.toUpperCase(); } is safe, because a local const cannot change.

  7. 7

    Hole 6: assertions and the readonly escape hatch. value as T is allowed whenever the types overlap (Topic 16.1), ! removes null and undefined by decree, readonly number[] can be cast to number[] with as, and an object can be assigned to a wider property type ({ a: string } to { a: string | number }), after which a write through the wider view breaks the narrower one. All of these are you telling the checker to trust you. The last example shows the two worst: a mutation of a "readonly" array, and an a that is suddenly a number.

  8. 8

    Staying safe, in practice. (1) Turn on strict and noUncheckedIndexedAccess. (2) Treat external data as unknown and check it. (3) Prefer readonly arrays and properties for anything you only read. (4) Declare callback members as properties, not methods. (5) Copy narrowed values into a const before calling anything. (6) In review, grep for as, ! and any. (7) Use typescript-eslint rules: no-explicit-any, no-unsafe-assignment, no-unsafe-member-access, no-unsafe-call, no-unsafe-return, no-unsafe-argument, no-unsafe-type-assertion, no-non-null-assertion, no-unsafe-declaration-merging and method-signature-style all exist in typescript-eslint 8.

Explain it without notes

01

Is TypeScript's type system sound? Name three places where it is not.

02

Why is Dog[] assignable to Animal[], and what can go wrong?

03

What is the difference between a method and a function-type property under strictFunctionTypes?

04

Why is JSON.parse dangerous in a strict codebase, and what do you do?

05

What habits and lint rules would you put in place to stay safe?

Practice

01

Fix this function so passing a Dog[] is allowed but the function cannot push into it. ``ts function countNames(animals: Animal[]): number { return animals.filter((a) => a.name.length > 0).length; }

02

Rewrite the narrowing example so that it does not crash even though reset removes the email.

Trade-offs

  • ↔

    Each safety habit has a cost: readonly everywhere needs more annotations, noUncheckedIndexedAccess adds checks after array reads, and unknown needs validation code. The cost is paid once; a crash found in production is paid by a user.

  • ↔

    Lint rules for any and as produce noise in older code. Introduce them with warnings first and tighten them file by file (Topic 15.4).

Done when you can

  • I can show array covariance crashing and fix it with a readonly view.

  • I can explain method bivariance and why callbacks should be properties.

  • I can name where any enters a program without being typed.

  • I can explain noUncheckedIndexedAccess and why narrowing across calls is unsafe.

  • I can list the flags, habits and lint rules that close the holes.