Limits & soundness
See where TypeScript lets unsafe code through on purpose, why its design allows it, how to close each gap, and what to do when types get too deep.
- 01Name the unsound spotsSpot the places where code passes the checker and still fails: any, as, !, index reads, wider arrays, methods, and optional members.
- 02Close the gaps you canSwap each hole for a checked version: unknown with a check, annotations, readonly, and stricter settings.
- 03Know when to stopRead recursion and size errors, keep types simple enough to check, and check outside data while the code runs.
The checker says yes, and the code still crashes
Control flow analysis showed a call that cleared a coupon while TypeScript said nothing. That wasn't a bug in TypeScript. It is one of a few places where TypeScript lets unsafe code through on purpose. Here is a more common one:
type User = { name: string; cartCount: number }; const reply = '{"name":"Asha"}';const user = JSON.parse(reply) as User;console.log(`${user.name} has ${user.cartCount.toFixed(0)} items`);Line 1 describes a User with a name and a number of cart items. Line 3 is a reply from a server, as text, and it has no cartCount. Line 4 parses it. JSON.parse returns any, a type that turns checking off, and as User tells TypeScript to treat the result as a User. TypeScript reports no errors. Line 5 reads user.cartCount, which is undefined, and toFixed throws a TypeError.
A type system is sound when every program it accepts is free of type errors when it runs: no value ever reaches a place whose type rules it out. TypeScript is not sound. That doesn't mean it's broken. It means it made a few choices, and knowing them tells you exactly where to add a check.
An office has a guard who checks every badge at the front door. Most people without a badge are stopped. But the guard lets in anyone with a note from a staff member, without phoning to check. There's a side door that nobody watches. And once someone is inside, the guard never sees them again. The office is much safer with the guard, and you still lock the server room.
- In real life: The guard checks every badge at the front door
- In TypeScript: TypeScript checks your code before it runs
- In real life: A visitor waves a note saying "I'm with them"
- In TypeScript:
as Usertells TypeScript to trust you - In real life: A side door nobody watches
- In TypeScript: A value typed
any - In real life: The guard can't see what happens inside after you pass
- In TypeScript: Nothing checks values while the code runs
TypeScript is not sound, on purpose. It catches most type mistakes, lets a few known patterns through so that everyday JavaScript fits, and checks nothing while the code runs. Knowing those few spots tells you where to be careful.
Soundness vs usefulness
The TypeScript team wrote down what the language aims for, on the TypeScript Design Goals page. After the goals comes a list of non-goals: things TypeScript deliberately doesn't try to do. The third non-goal is about soundness:
Non-goal 3: "Apply a sound or 'provably correct' type system. Instead, strike a balance between correctness and productivity."
In plain words: a fully sound checker would reject a lot of everyday JavaScript, and people would stop using it. TypeScript accepts some risk so that it fits the code people already write. The handbook's note on soundness says the same: "The places where TypeScript allows unsound behavior were carefully considered".
The table below lists four lines from that page. Read each row as: what the page says, what it means, and what it means for soundness.
| What the page says | In plain words | What it means for soundness |
|---|---|---|
| Goal 1: Statically identify constructs that are likely to be errors. | Catch the mistakes people really make, before the code runs. | Common bugs, like a typo or a missing property, are errors. Rare ones may slip through. |
| Goal 3: Impose no runtime overhead on emitted programs. | Add nothing to the JavaScript that runs. | No checks run in the browser, so data from outside is never checked by TypeScript. |
| Goal 7: Preserve runtime behavior of all JavaScript code. | Your code does the same thing with or without types. | Types can't change what the code does, so they can only report, not stop, a bad value. |
| Non-goal 3: Apply a sound or "provably correct" type system. | Being provably right everywhere is not a goal. | Some patterns are allowed even though they can fail, because rejecting them would block a lot of working code. |
Goals 3 and 7 explain the last part of this lesson. Types are removed before the code runs (see How TypeScript works), so nothing checks the data your page receives.
Holes you open yourself
Some holes only open when you write them. Each one tells TypeScript to stop checking a value:
any, which turns checking off (see any, unknown, and never);as, which asks TypeScript to trust you (see Type assertions);!after a value, which removesnullandundefined(see null and undefined);- reading a list by index, which TypeScript assumes finds an item (see tsconfig strictness);
- passing a list where a wider list goes, which lets the wider name push into it (see Variance).
Pick a hole, then close it, and watch what TypeScript says:
type User = { name: string; cartCount: number }; const saved: any = JSON.parse('{"name":"Asha"}');const user: User = saved;console.log(user.cartCount.toFixed(0));anyNo errorsthrows a TypeErrorany turns checking off for saved, so line 4 stores it as a User with no question asked. The reply has no cartCount, and line 5 throws a TypeError.
Every version throws when it runs, closed or not: the types are removed first. Closing a hole doesn't make the code work. It makes TypeScript tell you it won't, while you write it, instead of a shopper finding out.
Holes built into the rules
Other holes need no any or as. They come from rules TypeScript chose so that common JavaScript fits. Variance showed two: lists are treated as covariant (the Push hole above), and methods are checked both ways:
type Product = { name: string; price: number };type SaleProduct = Product & { salePrice: number }; interface ListProps { onSelect(product: Product): void } const props: ListProps = { onSelect: (item: SaleProduct) => console.log(item.salePrice.toFixed(2)) };props.onSelect({ name: "Tea", price: 3 });Line 4 writes onSelect as a method. Line 6 gives it a handler that needs a sale price. TypeScript allows it, because method parameters are checked both ways, even with strict on. Line 7 calls it with tea, which has no salePrice, and toFixed throws. Written as a property, onSelect: (product: Product) => void, line 6 would be an error.
A narrower type can also hide a property, and an optional property can then bring it back with the wrong type:
type Profile = { name: string; age: number };type Card = { name: string };type Contact = { name: string; age?: string }; const profile: Profile = { name: "Asha", age: 31 };const card: Card = profile;const contact: Contact = card;console.log(contact.age?.toUpperCase());- Line 5 makes a profile whose
ageis the number31. - Line 6 stores it as a
Card, which only listsname. That's allowed: extra properties are fine (see Structural typing). The type has forgotten the age, but the object hasn't. - Line 7 stores the card as a
Contact.Contact'sageis optional, andCardsays nothing about an age, so that's allowed too. - Line 8 calls
toUpperCaseoncontact.age, which is really31. It throwscontact.age?.toUpperCase is not a function.
Without the middle step, TypeScript catches it:
type Profile = { name: string; age: number };type Contact = { name: string; age?: string }; const profile: Profile = { name: "Asha", age: 31 };const contact: Contact = profile;Line 5 gets an error on purpose: Type 'Profile' is not assignable to type 'Contact', and the next line of the message gives the reason, Types of property 'age' are incompatible. The hole needs the Card step, which forgets the age. Excess property checks don't help: they only look at object literals written in place, and line 6 of the first example stores a variable.
Parameters have a hole too. A function with fewer parameters fits where more are passed. That one is safe, and you use it all the time:
const prices = [3, 4, 5];prices.forEach((price) => console.log(price));forEach calls the callback with three arguments: the item, its index, and the list. The callback on line 2 takes only the first and ignores the rest, so it prints 3, 4, and 5. Optional parameters are where it isn't safe. The handbook calls the rule for them "unsound from a type system perspective":
const formatPrice = (price: number, currency?: string) => `${price} ${currency?.toUpperCase() ?? "USD"}`; const short: (price: number) => string = formatPrice;const withSale: (price: number, onSale: boolean) => string = short;console.log(withSale(3, true));Lines 1 and 2 make formatPrice, which takes a price and an optional currency. Line 4 stores it as a function that takes only a price: fine. Line 5 stores that as a function that takes a price and a boolean: also fine, because a function with fewer parameters fits. But the function is still formatPrice, so line 6 passes true as its currency, and currency?.toUpperCase is not a function.
Where TypeScript stays strict: Object.keys
Sometimes the choice goes the other way, and TypeScript is stricter than you'd like. Object.keys is the best-known case:
type Prices = { tea: number; cake: number };const prices: Prices = { tea: 3, cake: 4 }; for (const key of Object.keys(prices)) { console.log(prices[key]);}Line 4 loops over Object.keys(prices), and line 5 reads prices[key]. TypeScript marks line 5 on purpose: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'Prices'. In plain words: key is any string, and Prices has no entry for any string. Object.keys returns string[], not ("tea" | "cake")[], because an object can have more keys than its type lists:
type Prices = { tea: number; cake: number };const menu = { tea: 3, cake: 4, note: "Fresh today" };const prices: Prices = menu; let total = 0;for (const key of Object.keys(prices) as (keyof Prices)[]) { total += prices[key];}console.log(total);Line 2's menu has an extra note. Line 3 stores it as Prices, which is allowed: extra properties fit. Line 6 casts the keys to (keyof Prices)[], which is what people wish Object.keys returned, and TypeScript reports nothing. But the loop also visits note, so line 7 adds "Fresh today" to the total, and line 9 prints 7Fresh today.
prices has the type Prices, which lists two keys. The object it points at is menu, with three. Object.keys reads the real object, so it finds note too.When you know the keys you want, list them yourself:
type Prices = { tea: number; cake: number };const menu = { tea: 3, cake: 4, note: "Fresh today" };const prices: Prices = menu; const items = ["tea", "cake"] as const;let total = 0;for (const key of items) { total += prices[key];}console.log(total);Line 5 lists the keys with as const (see const assertions and satisfies), so key is "tea" | "cake". Lines 7 and 8 add only those two prices, and line 10 prints 7. Here TypeScript's strictness was right, and the cast was the hole.
How deep a type can go
A recursive type refers to itself, the way a recursive function calls itself. Writing utility types showed how to write them. The checker has to stop somewhere, or a type that never ends would freeze your editor:
type Tuple<Item, Length extends number, Items extends Item[] = []> = Items["length"] extends Length ? Items : Tuple<Item, Length, [...Items, Item]>; type Week = Tuple<number, 7>;// ^?type Huge = Tuple<number, 1000>;Lines 1 and 2 describe Tuple: if the list Items already has Length items, that's the answer; if not, call Tuple again with one more item. Line 4 asks for 7 numbers, like a week of daily sales, and the query on line 5 shows [number, number, number, number, number, number, number]. Line 6 asks for 1,000, and TypeScript stops with an error, on purpose: Type instantiation is excessively deep and possibly infinite (error 2589). "Instantiation" means filling in a generic type's parameters. In TypeScript 5.9 this type works up to 999 items and fails at 1,000.
Tuple is tail-recursive: when it calls itself, that call is the whole answer of the branch, with nothing added around it. Since TypeScript 4.5, the checker handles such types without keeping every step, so they can go much deeper. The same type written so that each step adds to the next step's result fails far sooner:
type Tuple<Item, Length extends number, Count extends unknown[] = []> = Count["length"] extends Length ? [] : [Item, ...Tuple<Item, Length, [...Count, unknown]>]; type Week = Tuple<number, 7>;type Row = Tuple<number, 60>;Line 2 now puts an Item in front of what the next call gives back: [Item, ...Tuple<…>]. Line 4 still works, but line 5 asks for only 60 items and gets the same error 2589.
A different limit is about size:
type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"; type Pin = `${Digit}${Digit}${Digit}${Digit}`;type LongPin = `${Digit}${Digit}${Digit}${Digit}${Digit}`;Line 1 lists the ten digits as text. Line 3's Pin is every 4-digit text, a union of 10,000 members, and that's fine. Line 4 would need 100,000, and TypeScript marks it on purpose: Expression produces a union type that is too complex to represent (error 2590).
Neither limit is a setting you can raise. When you hit one, it's usually time to stop making the type fancier:
- Prefer a plainer type and a check while the code runs:
number[]and a length check, not a 1,000-item tuple. - If you need recursion, end each branch with the call itself, and carry the result in an extra type parameter, as
TuplecarriesItems. - Ask what the type catches that a simpler type and a test wouldn't. Deep types also slow down every check in your editor. Habits of good TypeScript covers type performance.
Runtime checks still matter
Every hole so far ends the same way: a value that doesn't match its type, found while the code runs. The design goals explain why TypeScript can't help then: the types are gone. Anything that arrives from outside, like a server reply, localStorage, or the URL, is unchecked. Check it yourself, at the edge, where it enters your code:
type User = { name: string; cartCount: number }; function isUser(value: unknown): value is User { return ( typeof value === "object" && value !== null && "name" in value && typeof value.name === "string" && "cartCount" in value && typeof value.cartCount === "number" );} const saved: unknown = JSON.parse('{"name":"Asha"}');if (isUser(saved)) { console.log(`${saved.name} has ${saved.cartCount} items`);} else { console.log("The saved user is out of date");}Line 3's isUser is a type guard: it takes unknown and returns value is User. Lines 5 to 7 check that the value is an object with a text name and a number cartCount. Line 11 parses the saved user as unknown, so nothing can use it unchecked. Line 12 runs the check. This saved user has no cartCount, so line 15 prints The saved user is out of date instead of crashing. Inside line 13, saved is a User.
isUser checks it at the edge, so the app shows a message instead of crashing on the missing cartCount.Writing guards by hand gets long for big replies. Typing fetch and JSON shows more ways to check a server's reply.
Auditing a settings loader
This project turns on noUncheckedIndexedAccess. Here is a settings loader that still passes the checker with three holes in it:
type Settings = { theme: "light" | "dark"; fontSizes: number[] }; function loadSettings(text: string) { const settings = JSON.parse(text) as Settings; const largest = settings.fontSizes[settings.fontSizes.length]!; return `${settings.theme}, ${largest.toFixed(0)}px`;} console.log(loadSettings('{"theme":"dark","fontSizes":[14,16]}'));- Line 4:
as Settingstrusts whatever the text holds. - Line 5 reads index
length, one past the end: the last item is atlength - 1. The setting would mark it as possiblyundefined, but the!turns that check off. - Line 6 calls
toFixedonundefined, and line 9's call throws a TypeError.
type Settings = { theme: "light" | "dark"; fontSizes: number[] }; function isSettings(value: unknown): value is Settings { return ( typeof value === "object" && value !== null && "theme" in value && (value.theme === "light" || value.theme === "dark") && "fontSizes" in value && Array.isArray(value.fontSizes) && value.fontSizes.every((size) => typeof size === "number") );} function loadSettings(text: string) { const settings: unknown = JSON.parse(text); if (!isSettings(settings)) return "default settings"; const largest = settings.fontSizes.at(-1); if (largest === undefined) return `${settings.theme}, no sizes`; return `${settings.theme}, ${largest.toFixed(0)}px`;} console.log(loadSettings('{"theme":"dark","fontSizes":[14,16]}'));console.log(loadSettings('{"theme":"blue"}'));- Lines 3 to 10 are a guard: the theme must be
"light"or"dark", andfontSizesmust be a list of numbers. - Line 13 parses the text as
unknown, and line 14 falls back to the defaults when the guard says no. - Line 15's
.at(-1)gives the last size, orundefinedfor an empty list, and line 16 handles that.
Line 20 prints dark, 16px. Line 21 prints default settings, because "blue" isn't a theme.
const count: number = JSON.parse('"three"');const saved: unknown = JSON.parse('"three"'); const count: number = saved;const user = {} as { name: string };const user: { name: string } = {};const sizes: string[] = []; const first: string = sizes[0];const keys: ("tea" | "cake")[] = Object.keys({ tea: 3, cake: 4 });const teas: string[] = ["green"]; const drinks: (string | number)[] = teas; drinks.push(3);type T<N extends unknown[]> = N["length"] extends 1000 ? N : T<[...N, 0]>; type Big = T<[]>;
Every line here is unsafe. Sort each one by what TypeScript says about it, with strict on. Each line is checked on its own.
Common mix-ups
The table below sums up the holes in this lesson. Read each row as: the hole, what slips through it, and how to close it.
| The hole | What slips through | How to close it |
|---|---|---|
any | Any use of the value, with no checks at all. | Use unknown and check the value before you use it. |
as Type | A value that doesn't match the type you claim. | Annotate with : Type, or check the value with a type guard. |
value! | null or undefined where the type says there's none. | Check for the missing value, or use ?. and ??. |
items[i] | undefined when the index is past the end. | Turn on noUncheckedIndexedAccess, or use .at(). |
| A list or object passed as a wider type | A write through the wider name that breaks the original. | Use readonly where you only read. |
| A method in an interface | A callback that needs more than the caller gives it. | Write callbacks as properties with function types. |
| An optional property | A value of another type that was hidden by a narrower type. | Copy only what you need into a new object. |
| A function with optional parameters | Arguments of the wrong type in those positions. | Keep callback types exact, and avoid optional parameters in callbacks. |
- "No type errors means no type problems when the code runs." No. TypeScript is not sound. Values from
any,as, index reads, and outside data can still be the wrong type. - "Every unsound spot is a bug in TypeScript." No. Most are choices, made so that everyday JavaScript fits. The design goals say soundness is not a goal.
- "
asturns a value into another type." No. It changes only what TypeScript believes. Nothing happens when the code runs. - "
strictcloses every hole." No.noUncheckedIndexedAccessis a separate setting, methods are still checked both ways, andany,as, and!still work. - "
Object.keysreturningstring[]is a mistake." No. An object can have more keys than its type lists, sostring[]is the true answer. - "A fancier type is always safer." No. Deep types hit the checker's limits and slow your editor. A simple type and a runtime check often catch more.
Practice exercises
Each exercise is TypeScript. When you fix code, press Edit & run on the starter code: the errors update as you type, and the console shows what it prints. Find where checking stopped, then use the hints.
TypeScript reports no errors. What does this code print? Then make it check the value instead of trusting it.
type Settings = { theme: string; fontSize: number }; const settings = JSON.parse('{"theme":"dark"}') as Settings;console.log(settings.fontSize + 2);type Settings = { theme: string; fontSize: number }; const saved: unknown = JSON.parse('{"theme":"dark"}');const fontSize = typeof saved === "object" && saved !== null && "fontSize" in saved && typeof saved.fontSize === "number" ? saved.fontSize : 16;console.log(fontSize + 2);The starter prints NaN: fontSize is undefined, and undefined + 2 is NaN. The fix parses into unknown, checks for a number fontSize, and falls back to 16, so it prints 18.
This project turns on noUncheckedIndexedAccess. Which line will TypeScript mark?
const sizes = ["S", "M", "L"];
const first = sizes[0];
const label = first.toLowerCase();
console.log(label);const sizes = ["S", "M", "L"];const first = sizes[0];const label = first?.toLowerCase() ?? "none";console.log(label);With the setting, first is string | undefined, so line 3 gets 'first' is possibly 'undefined'. ?. and ?? handle the missing item, and it prints s.
The second call throws. Make the parameter safe so it returns 0 for a reply with no items. What does it print?
function cartCount(saved: any) { return saved.items.length;} console.log(cartCount(JSON.parse('{"items":[1,2]}')));console.log(cartCount(JSON.parse("{}")));function cartCount(saved: unknown) { if (typeof saved === "object" && saved !== null && "items" in saved && Array.isArray(saved.items)) { return saved.items.length; } return 0;} console.log(cartCount(JSON.parse('{"items":[1,2]}')));console.log(cartCount(JSON.parse("{}")));With unknown, every read needs a check first. The empty object fails the check and gives 0 instead of throwing. It prints 2, then 0.
TypeScript marks line 6. Fix it without as. What does it print?
type Prices = { tea: number; cake: number };const prices: Prices = { tea: 3, cake: 4 }; let total = 0;for (const key of Object.keys(prices)) { total += prices[key];}console.log(total);type Prices = { tea: number; cake: number };const prices: Prices = { tea: 3, cake: 4 }; const items = ["tea", "cake"] as const;let total = 0;for (const key of items) { total += prices[key];}console.log(total);["tea", "cake"] as const makes each key "tea" | "cake", so prices[key] is a number. Only those two are added, even if the object has more. It prints 7.
TypeScript says contact.age is string | undefined. What does the starter print? Then change line 6 so the hole closes.
type Profile = { name: string; age: number };type Card = { name: string };type Contact = { name: string; age?: string }; const profile: Profile = { name: "Asha", age: 31 };const card: Card = profile;const contact: Contact = card;console.log(typeof contact.age);type Profile = { name: string; age: number };type Card = { name: string };type Contact = { name: string; age?: string }; const profile: Profile = { name: "Asha", age: 31 };const card: Card = { name: profile.name };const contact: Contact = card;console.log(typeof contact.age);The starter prints number, even though Contact says age is string | undefined. Building a new card with only the name, as here, leaves the age behind, so it prints undefined.
TypeScript marks line 6 with error 2589. Rewrite Row so it's tail-recursive, and add a query under SeatCount. What does it show?
type Seat = { row: string; seat: number }; type Row<Length extends number, Count extends unknown[] = []> = Count["length"] extends Length ? [] : [Seat, ...Row<Length, [...Count, unknown]>]; type BigRow = Row<60>;type SeatCount = BigRow["length"];type Seat = { row: string; seat: number }; type Row<Length extends number, Seats extends Seat[] = []> = Seats["length"] extends Length ? Seats : Row<Length, [...Seats, Seat]>; type BigRow = Row<60>;type SeatCount = BigRow["length"];// ^?Now the call to Row is the whole branch, so the type is tail-recursive and the checker can go much deeper. The error on line 6 is gone, and the query shows type SeatCount = 60.
Check your understanding
For each question, ask where the value comes from and whether anything checked it. The quiz shows its code without the checker, so the error list doesn't give the answers away.
Question 1 of 7What would it mean for a type system to be sound?
Choose an answer to see the explanation.
Question 2 of 7What do TypeScript's design goals say about soundness?
Choose an answer to see the explanation.
Question 3 of 7TypeScript reports no errors for this code. What does it print?
Read the code, then predictconst quantities = ["1", "2", "3"].map(parseInt); console.log(quantities);Choose an answer to see the explanation.
Question 4 of 7With the default strict settings, what happens with this price list?
Read the code, then predictconst prices = [3, 4, 5]; const fourth = prices[3]; console.log(fourth.toFixed(2));Choose an answer to see the explanation.
Question 5 of 7Why does
Object.keys(prices)givestring[]and not("tea" | "cake")[]?Choose an answer to see the explanation.
Question 6 of 7TypeScript reports no errors for this handler code. What does it print?
Read the code, then predicttype Handler = (price: number, onSale: boolean) => string; const label = (price: number, currency?: string) => `${price} ${currency ?? "USD"}`; const short: (price: number) => string = label; const handler: Handler = short; console.log(handler(3, true));Choose an answer to see the explanation.
Question 7 of 7A helper type shows
Type instantiation is excessively deep and possibly infinite. What is usually the best first step?Choose an answer to see the explanation.
Key takeaways
- A sound checker never accepts code with a type error. TypeScript is not sound, on purpose: its design goals trade some correctness for productivity.
any,as,!, index reads, and lists passed as wider lists stop checks.unknown, annotations,noUncheckedIndexedAccess, andreadonlyclose them.- Methods, hidden optional properties, and optional parameters let some unsafe code through with no cast at all.
Object.keysgivesstring[]because objects can have more keys than their types list.- Recursive types stop at a limit (error 2589), and huge unions too (error 2590). A simpler type is usually the answer.
- Types are gone when the code runs. Check outside data at the edge, with a guard.
Remember the one-liner.
TypeScript checks your code, not your data, and it lets a few known patterns through on purpose.
This is the last lesson of the TypeScript track. Where to go next:
- Type a small project in the code editor, with
strictandnoUncheckedIndexedAccesson, and search it foras,any, and!. - Review Habits of good TypeScript and Reading type errors with this lesson in mind.
- Read the types of a library you use: its
.d.tsfiles (see Declaration files). - Keep the TypeScript release notes handy: each version changes what the checker can follow.
Coming next: you've finished the track. Go back to the TypeScript track to review any stage.