The Type-System Mindset
A type is a set of values
Section titled “A type is a set of values”Here is the reframe that unlocks TypeScript. Stop thinking of a type as a label and start thinking of it as a set — the collection of every value that legally belongs to it.
booleanis the set{ true, false }— exactly two members.undefinedis a set with one member:{ undefined }.stringis the (infinite) set of all strings.42— yes, a literal — is a set with one member:{ 42 }. This is a literal type.
Once types are sets, the operations you already know from math explain the whole system.
Assignability is the subset relationship
Section titled “Assignability is the subset relationship”a is assignable to b if and only if the set a is a subset of the set b. That’s the entire rule.
// @expect-error TS2322let x: 42 = 42;let y: number = x; // ✅ {42} ⊆ all numbers
let a: number = 42;let b: 42 = a; // ❌ all numbers ⊄ {42}// Error: Type 'number' is not assignable to type '42'.The literal 42 fits into number because {42} is a subset of all numbers. The reverse fails because most numbers aren’t 42. You never have to memorize assignability rules — you just ask “is this set inside that set?”
Unions are set union; intersections are set intersection
Section titled “Unions are set union; intersections are set intersection”flowchart TB
subgraph u["A | B (union = OR)"]
ua["string"] --- ub["number"]
note1["a value in EITHER set
→ more values, wider"]
end
subgraph i["A & B (intersection = AND)"]
note2["a value in BOTH sets
→ fewer values, narrower"]
end string | numberis the union of the two sets — a value that’s in either. Unions are wider (more members).A & Bis the intersection — a value that’s in both. For object types this means “has all properties of both,” which is why intersections narrow.
A common surprise falls right out of this: string & number is the empty set. No value is both a string and a number — so its type is never.
The two ends: never and unknown
Section titled “The two ends: never and unknown”Sets have a smallest and largest, and TypeScript names them:
neveris the empty set{ }— no values at all. Nothing is assignable tonever(you can’t produce a member of an empty set), butneveris assignable to everything (the empty set is a subset of every set). It’s what a function that never returns is typed as, and the signal that a branch is unreachable.unknownis the set of all values — the top type. Everything is assignable tounknown, butunknownis assignable to almost nothing until you narrow it. It’s the type-safe version ofany.
// @expect-error TS2322let n: never;let u: unknown = "anything goes";
let s: string = u; // ❌ unknown is too wide — narrow it firstlet x: unknown = 42; // ✅ everything fits into unknownany breaks this model on purpose: it opts out of set-checking entirely, which is why it’s dangerous. unknown keeps the model and forces you to narrow.
Proof: any, unknown, and never on the exact same mistake
Section titled “Proof: any, unknown, and never on the exact same mistake”Talk is cheap — put all three in front of the same bug and watch the model predict exactly what happens to each one.
// @runconst withAny: any = "42";withAny.toFixed(2); // compiles — any opts out of checking, crashes at runtimeTypeError: withAny.toFixed is not a functionany isn’t a member of the sets model at all — it’s a hole punched through it. The compiler lets .toFixed through with zero checking, and the mistake (a string has no toFixed) survives all the way to a runtime crash.
Same mistake, three types: any crashes, unknown and never don't compile
// @expect-error TS18046 TS2339const withAny: any = "42";withAny.toFixed(2); // compiles — any opts out of checking, crashes at runtime
const withUnknown: unknown = "42";withUnknown.toFixed(2); // TS18046 — unknown forces you to narrow first
declare const withNever: never;withNever.toFixed(2); // TS2339 — never has no members at all Click Check types: any’s line produces nothing, because any is assignable to and from everything, no questions asked. unknown’s line fails with TS18046 — unknown is the set of all values, and .toFixed isn’t a property every value has, so the compiler refuses until you narrow. never’s line fails with TS2339 for a different reason: never is the empty set, so it has no properties at all — there’s nothing to call .toFixed on, ever. Same shape of mistake, three completely different outcomes, and the sets model predicts every one of them: any isn’t a set (it’s an escape hatch), unknown is the biggest set (nothing narrowed yet), never is the smallest set (nothing there to use).
never in practice: unreachable code and impossible cases
Section titled “never in practice: unreachable code and impossible cases”never isn’t just the empty set in the abstract — it’s the real inferred type of a function that can’t return normally:
function fail(msg: string): never { throw new Error(msg);}
const result: never = fail("boom"); // ✅ — fail never returns, so this line never runsThis compiles because fail genuinely never produces a value — every path throws. The compiler trusts a never-returning function completely: any code after a call to one is understood to be unreachable.
The same idea shows up as a safety net in a switch. When every case of a union has been handled, whatever’s left over has been narrowed to never — assignable to never because there’s genuinely nothing left it could be:
type Shape = { kind: "circle"; radius: number } | { kind: "square"; side: number };
function area(shape: Shape): number { switch (shape.kind) { case "circle": return Math.PI * shape.radius ** 2; case "square": return shape.side ** 2; default: const exhaustive: never = shape; // ✅ every case handled — nothing reaches here throw new Error(`Unhandled shape: ${exhaustive}`); }}Add a third member to Shape later and forget to handle it, and this exact line stops compiling — shape in the default branch is no longer narrowed all the way to never, so assigning it to a never-typed variable fails:
// @expect-error TS2322type Shape = | { kind: "circle"; radius: number } | { kind: "square"; side: number } | { kind: "triangle"; base: number; height: number };
function area(shape: Shape): number { switch (shape.kind) { case "circle": return Math.PI * shape.radius ** 2; case "square": return shape.side ** 2; default: const exhaustive: never = shape; // ❌ 'triangle' case wasn't handled throw new Error(`Unhandled shape: ${exhaustive}`); }}This is the never-as-empty-set idea doing real work: the moment shape could genuinely still be a { kind: "triangle"; ... }, its set of possible values is no longer empty, so it no longer fits in never, and the compiler tells you a case is missing — at compile time, not by silently falling through. The narrowing lesson covers exhaustiveness checks in full.
Mental Model
Section titled “Mental Model”Every type in this lesson is a point on the same scale: never is the empty set at the bottom, unknown is the set of everything at the top, and every other type is somewhere in between, sized by how many values it allows. any is not on that scale at all — it is a deliberate hole in the model, which is exactly why the same .toFixed mistake behaves so differently depending on which of the three you used.
flowchart LR never["never (empty set — bottom)"] --> mid["string, number, User, ... (sized sets in between)"] mid --> unknown["unknown (all values — top)"] any["any (NOT a set — opts out entirely)"] -.->|breaks the model on purpose| mid
Trade-offs
Section titled “Trade-offs”| Type | Benefit | Cost |
|---|---|---|
any | No friction — every operation is allowed | Opts out of the sets model entirely; the exact mistake this lesson demonstrates compiles clean and only surfaces at runtime |
unknown | Accepts anything, like any, but keeps every check | Cannot be used for anything until narrowed — more upfront work at the boundary |
never | Makes “this branch is impossible” a compile-time fact, not a comment | Only useful once every other case has genuinely been handled; adding a case later without updating the default produces a real (correct) compile error |
Exhaustive switch with a never check | Catches an unhandled union member the moment it’s added | Requires the default: const x: never = value boilerplate at every switch that needs the guarantee |
Common Misconceptions
Section titled “Common Misconceptions”anyandunknownare basically the same “give up” type. They are opposites in practice:unknownkeeps the sets model intact and blocks every operation until you narrow;anydisables the model, which is why identical code (x.toFixed(2)) compiles underanyand fails underunknown.nevermeans “an error occurred.”nevermeans “the empty set” — no value can ever have this type. A function typed to returnneverisn’t broken; it’s a function that provably never returns at all (it always throws or loops).- An exhaustive
switchneeds a comment saying “this shouldn’t happen.” It needs anever-typed variable in thedefaultbranch instead — a comment can’t stop compiling when a new union member is added and left unhandled, but an assignment tonevergenuinely can.
Reading AI-generated code
Section titled “Reading AI-generated code”Generated code reaches for any far more often than unknown, because any requires no follow-up narrowing — which is precisely why it hides the class of bug this lesson opened with. When reviewing a type annotation on untrusted or external data, check:
- Is it
anywhere it should beunknown? If nothing downstream narrows the value before using it,anywill let a real mistake compile clean, exactly likewithAny.toFixed(2)above. - Does an exhaustiveness
switchactually have anever-typeddefault, or does it just have a comment claiming every case is covered? - If a function is annotated
: never, does it genuinely never return (throw/loop in every path), or wasneverused as a stand-in for “I don’t know this type yet”?
🐛 Spot the bug
function getSetting(key: string): any {const settings: Record<string, string> = { theme: "dark" };return settings[key];}
const timeout = getSetting("timeout");console.log(timeout.toFixed(0));Show the answer
This compiles with zero errors and crashes at runtime. getSetting is annotated to return any, so every use of its result — including .toFixed(0), a number-only method — is completely unchecked. settings["timeout"] is actually undefined (the key doesn’t exist in settings), so timeout.toFixed(0) throws TypeError: Cannot read properties of undefined (reading 'toFixed'). Had getSetting returned unknown instead, this exact call site would fail to compile with TS18046 — forcing a narrowing check before the crash could ever reach runtime. any on a function whose real behavior is “look this up and maybe not find it” is a strictly worse choice than unknown plus a real check.