TypeScript 型別系統刻意不追求健全性:7 個通過型別檢查後仍會崩潰的程式
TypeScript Is Unsound on Purpose: 7 Programs That Type-Check, Then Crash
TypeScript 的型別系統刻意在正確性與開發效率之間取捨,因此 7 種程式可通過 --strict 檢查,卻仍可能在執行時出錯。例子涵蓋陣列協變、方法參數雙變、回呼期間的型別窄化、未檢查索引、可選屬性資訊遺失、型別斷言及錯誤的型別守衞,並各自説明修補方式。
- Book: The TypeScript Type System
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Non-goal number three on the TypeScript Design Goals page reads: "Apply a sound or 'provably correct' type system. Instead, strike a balance between correctness and productivity." The team wrote that down on purpose.
The handbook says the same thing in its own words. The type compatibility chapter has a section called "A Note on Soundness" that says TypeScript "allows certain operations that can't be known at compile-time to be safe," and that the places where it does so "were carefully considered."
So a green tsc --strict run tells you your code stayed inside the trade-offs the team picked, and those trade-offs include programs that throw a TypeError. Below are seven programs that compile under --strict and fail at runtime, the rule that lets each one through, and what closes it, where something does. Every rule is linked to the doc that states it, so you can paste any snippet into the TypeScript playground and check it yourself.
What --strict covers, and what it skips
The strict flag turns on the strict family: strictNullChecks, strictFunctionTypes, noImplicitAny, useUnknownInCatchVariables and five more. Two checks that matter below are not in that family: noUncheckedIndexedAccess and exactOptionalPropertyTypes. The TypeScript 4.1 release notes say the index check "is not automatically enabled by the strict flag" because "it might be noisy for a lot of code."
Every snippet here assumes --strict and nothing else. One of them dies the moment you add a single line to tsconfig.json. The rest need a pattern, a lint rule, or a runtime check, because no flag exists for them.
Variance: the array and the method
1. A cat in the dog array
class Animal {
name = "animal";
}
class Dog extends Animal {
bark() { return "woof"; }
}
class Cat extends Animal {
meow() { return "meow"; }
}
function adoptCat(shelter: Animal[]) {
shelter.push(new Cat());
}
const kennel: Dog[] = [new Dog()];
adoptCat(kennel);
for (const dog of kennel) dog.bark(); // TypeError on the Cat
Dog[] is assignable to Animal[] because arrays relate covariantly: if Dog fits Animal, then Dog[] fits Animal[]. That is safe for reading and wrong for writing. push takes an Animal, the Cat goes in, and the loop calls bark on a cat.
The fix: when a function only reads, type the parameter as readonly Animal[]. push doesn't exist on that type, so the write becomes a compile error. Callers can still pass a Dog[], because the handbook notes that a mutable array assigns to a ReadonlyArray but not the other way around.
2. The method that only takes strings
interface Handler {
handle(input: string | number): void;
}
const h: Handler = {
handle(input: string) {
console.log(input.toUpperCase());
},
};
h.handle(42); // TypeError at runtime: no toUpperCase on 42
strictFunctionTypes arrived in TypeScript 2.6 and checks function parameters contravariantly. It skips methods on purpose. The release notes say the stricter check covers all function types "except those originating in method or constructor declarations," and that "methods are excluded specifically to ensure generic classes and interfaces (such as Array<T>) continue to mostly relate covariantly." The TSConfig reference adds that the team found "a large number of inherently unsafe class hierarchies, including some in the DOM" while building the flag.
So a member written in method syntax stays bivariant, and an implementation that only accepts string gets through. That same carve-out is why program 1 compiles: push is a method on Array<T>.
The fix: declare the member as a property, handle: (input: string | number) => void. Now the same object literal is a compile error. The typescript-eslint rule method-signature-style enforces property style by default, and its docs state that "methods are always bivariant in their arguments, while function properties are contravariant."
Promises the checker doesn't track
3. A narrowed field that changes under you
interface Profile {
readonly nickname: string | null;
}
function greet(p: Profile, onGreet: () => void) {
if (p.nickname !== null) {
onGreet();
console.log(p.nickname.toUpperCase());
}
}
const live = { nickname: "kit" as string | null };
greet(live, () => {
live.nickname = null;
}); // TypeError at runtime: nickname is null
The object types handbook page says TypeScript "doesn't factor in whether properties on two types are readonly when checking whether those types are compatible, so readonly properties can also change via aliasing." live is mutable, greet sees it through a readonly lens, and the callback writes through the original reference. Delete readonly from Profile and the program still compiles. The modifier was never the guard.
What actually lets it through is narrowing. After p.nickname !== null, the compiler keeps that narrowing across the onGreet() call. Issue #9998, "Trade-offs in Control Flow Analysis", explains why: resetting every narrowing after every call is the pessimistic option, and Anders Hejlsberg called the optimistic assumption "the best compromise."
No flag covers this one. Copy the value into a local before you call out, const nick = p.nickname;, then check and use nick. A callback can't reassign your const, so that narrowing holds. And keep the asymmetry in mind: readonly arrays are checked, readonly properties are not.
4. The key that isn't there
const ports: Record<string, number> = { web: 8080 };
const db = ports["db"]; // typed as number
console.log(db.toFixed(0)); // TypeError at runtime: db is undefined
An index signature like Record<string, number> promises a number for any string key. ports["db"] is typed number and holds undefined. Arrays behave the same way: reading past the end gives you the element type, not undefined.
The fix: noUncheckedIndexedAccess. It adds undefined to every read through an index signature and every array index, so db.toFixed stops compiling until you check it. The cost is noise in index-based for loops, and the 4.1 notes point you at for...of or forEach instead.
Information lost on the way up
5. The optional property that was there all along
interface Base {
id: string;
}
interface Job {
id: string;
retries?: number;
}
const raw = { id: "job-1", retries: "three" };
const base: Base = raw; // extra property: fine
const job: Job = base; // missing optional: fine
console.log(job.retries?.toFixed(0)); // TypeError at runtime
Two legal steps. raw to Base: excess property checks run only on a fresh object literal, and the handbook's excess property checks section says assigning the object to another variable gets around them. Base to Job: Base has no retries, and a missing property satisfies an optional one. In the middle, the compiler forgets retries existed. At the end it assumes number | undefined. The string "three" isn't nullish, so ?. doesn't save you.
Nothing in tsconfig closes this. exactOptionalPropertyTypes only controls whether you may write undefined into an optional property. Exact object types, which would close it, have been an open proposal in issue #12936 since December 2016. When a reader raised this case on his Effective TypeScript repo, Dan Vanderkam called it "definitely another source of unsoundness" and pointed to Item 48 of his book, which covers it. The pattern that works: don't round-trip objects through a narrower interface, and validate data at the boundary where it enters your program.
Where you told the compiler to trust you
6. The assertion that skipped the check
interface Account {
id: string;
roles: string[];
}
const body = '{"id":"u1"}';
const data: unknown = JSON.parse(body);
const account = data as Account;
console.log(account.roles.includes("admin")); // TypeError
The everyday types handbook page says type assertions "are removed by the compiler," and that "there won't be an exception or null generated if the type assertion is wrong." The cast from unknown is allowed because assertions may move to a "more specific or less specific version of a type." account.roles is undefined at runtime.
The fix: replace the cast with a runtime check, either a schema validator such as Zod or a hand-written guard (see program 7 before you trust one). To ban casts outright, typescript-eslint's consistent-type-assertions accepts assertionStyle: "never", which still allows as const.
7. The type guard that lies
type Cat = { kind: "cat"; meow(): string };
type Dog = { kind: "dog"; bark(): string };
function isCat(pet: Cat | Dog): pet is Cat {
return "bark" in pet; // inverted check, still compiles
}
function adopt(): Cat | Dog {
return { kind: "dog", bark: () => "woof" };
}
const pet = adopt();
if (isCat(pet)) {
pet.meow(); // TypeError at runtime: a Dog has no meow
}
A type predicate is a promise you make. The compiler doesn't prove it. The narrowing handbook page says that whenever a guard is called, TypeScript "will narrow that variable to that specific type if the original type is compatible." It reads the signature. Nothing compares the body with the claim. Dan Vanderkam's post on unsoundness makes the same point: with a guard, "you're still asserting that your type guard really guards the type."
The fix: since TypeScript 5.5, the compiler can infer a predicate from the body when four conditions hold: no explicit return type or predicate annotation, a single return, no mutation of the parameter, and a boolean expression tied to a refinement of the parameter. An inferred predicate comes from the check itself, so the two can't disagree. Keep explicit predicates for checks the compiler can't follow, and give each one a unit test with a matching and a non-matching value.
Does TypeScript 7 change any of this?
TypeScript 7.0 shipped on July 8, 2026, with the compiler ported to Go in the typescript-go repo. The announcement says the port kept "the structure and logic of the original codebase" to keep results consistent, and that 7.0 "is made to be compatible with TypeScript 6.0's type-checking and command-line behavior."
The differences it lists are elsewhere: flags deprecated in 6.0 become hard errors, strict now defaults to true, template literal types preserve Unicode code points, JSDoc support was reworked, and changing the --checkers count can surface order-dependent results. None of those touch the seven rules above, so by the team's own compatibility statement all seven still compile on 7.0. The new strict default doesn't help either, since every program here already passes --strict.
The seven, side by side:
| # | Hole | What closes it |
|---|---|---|
| 1 | Array covariance |
readonly T[] parameters |
| 2 | Method bivariance | Property-style members, method-signature-style
|
| 3 | Narrowing kept across a callback | Copy to a const before callbacks |
| 4 | Unchecked index reads | noUncheckedIndexedAccess |
| 5 | Optional-property widening | Validate at the boundary (no flag) |
| 6 | Assertions | Runtime validation, assertionStyle: "never"
|
| 7 | Lying type guards | Inferred predicates, tested guards |
And the one config line that costs nothing to add:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true
}
}
TypeScript gives you a checker that catches most mistakes and tells you in writing which ones it lets through. Add noUncheckedIndexedAccess today, and put the other six on your review checklist.
If this was useful
Every program in this post comes down to one question: what does assignability actually check? The TypeScript Type System is the book on that question. It covers variance and bivariance, including the function-versus-method split behind programs 1 and 2, plus the generic, conditional and branded types you reach for when the built-in rules are too loose.
The TypeScript Library has five books. Start with the first two if TypeScript is your main language, swap in a bridge book if you are coming from the JVM or PHP, and add the fifth if you ship TypeScript at work:
- TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling
- The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types
- Kotlin and Java to TypeScript — JVM developers moving to TypeScript
- PHP to TypeScript — PHP 8+ developers moving to TypeScript
- TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR
All five books ship in ebook, paperback, and hardcover.
來源:DEV Community · dev.to
