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
anyentering 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.
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
Close the array hole
In the first example, change
const animals: Animal[] = dogs;toconst animals: readonly Animal[] = dogs;. Predict which line becomes the error, and what happens in the Console. The error moves toanimals.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
Turn a property into a method
In the second example, change
handle: (x: string | number) => void;inWithProptohandle(x: string | number): void;. Predict whether the error on thepline disappears. It does, and the Console still crashes onm.handle(42): the same function is now accepted. - 3
Copy before you call
In the narrowing example, rewrite
showto readconst email = u.email;first and testemail. Predict whether the program still crashes. It does not: the Console printsA@X.IO, because the localconstkeeps its string even afterreset(u)removes the property.
Code & diagrams
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 functionThe 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 functionparsed 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')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')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')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 functionThe 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
AshaBreak 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;.
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. - ▸
inandoutvariance 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-*catchesanyleaks,no-unsafe-type-assertionflags the narrowing-casts, andno-unsafe-declaration-mergingflags the class-plus-interface trap from Topic 16.3. They need type-aware linting (Topic 15.2).
Remember this
- 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
Hole 1: array covariance. An array of
Dogis accepted where an array ofAnimalis expected, because aDogis anAnimal. But the array can be written to: through theAnimal[]view you canpushaCat, and the originalDog[]now holds a cat. The first example crashes withd.bark is not a function. The fix is to take a read-only view:readonly Animal[]has nopush, so the unsafe write is a type error. - 3
Hole 2: method bivariance. A function type written as a property (
handle: (x: string | number) => void) is checked strictly understrictFunctionTypes: a function that only acceptsstringis 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, andm.handle(42)crashes. The reason is history: many built-in types (likeArray) 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
**Hole 3:
anyleaks.**anyswitches checking off, and it spreads: whatever you read from ananyisanyagain. Standard library functions that returnany, such asJSON.parse(andresponse.json()), put ananyinto your program without you writing the word. In the example,age: numberis accepted, becauseparsed.ageisany; thenage.toFixedcrashes. The fix isunknownat the boundary and a check or validation before use (Topic 13.4); the lint rulesno-explicit-anyand theno-unsafe-*family catch the leaks. - 5
Hole 4: index access.
names[2]has the element type,string, even when the array has two items. WithnoUncheckedIndexedAccess(part of thetsc --initset, so on by default in these examples) the type becomesstring | 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
Hole 5: narrowing is not invalidated by calls. After
if (u.email)the checker believesu.emailis astring. If you then call a function that changesu(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 aconstlocal:const email = u.email; if (email) { reset(u); email.toUpperCase(); }is safe, because a localconstcannot change. - 7
Hole 6: assertions and the readonly escape hatch.
value as Tis allowed whenever the types overlap (Topic 16.1),!removesnullandundefinedby decree,readonly number[]can be cast tonumber[]withas, 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 anathat is suddenly a number. - 8
Staying safe, in practice. (1) Turn on
strictandnoUncheckedIndexedAccess. (2) Treat external data asunknownand check it. (3) Preferreadonlyarrays and properties for anything you only read. (4) Declare callback members as properties, not methods. (5) Copy narrowed values into aconstbefore calling anything. (6) In review, grep foras,!andany. (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-mergingandmethod-signature-styleall exist in typescript-eslint 8.
Explain it without notes
Is TypeScript's type system sound? Name three places where it is not.
Why is Dog[] assignable to Animal[], and what can go wrong?
What is the difference between a method and a function-type property under strictFunctionTypes?
Why is JSON.parse dangerous in a strict codebase, and what do you do?
What habits and lint rules would you put in place to stay safe?
Practice
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;
}
Rewrite the narrowing example so that it does not crash even though reset removes the email.
Trade-offs
- ↔
Each safety habit has a cost:
readonlyeverywhere needs more annotations,noUncheckedIndexedAccessadds checks after array reads, andunknownneeds validation code. The cost is paid once; a crash found in production is paid by a user. - ↔
Lint rules for
anyandasproduce 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
anyenters a program without being typed.I can explain
noUncheckedIndexedAccessand why narrowing across calls is unsafe.I can list the flags, habits and lint rules that close the holes.