Skip to content

The Type-System Mindset

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.

  • boolean is the set { true, false } — exactly two members.
  • undefined is a set with one member: { undefined }.
  • string is 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.

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 TS2322
let 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
Types as sets: union widens, intersection narrows
  • string | number is the union of the two sets — a value that’s in either. Unions are wider (more members).
  • A & B is 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.

Sets have a smallest and largest, and TypeScript names them:

  • never is the empty set { } — no values at all. Nothing is assignable to never (you can’t produce a member of an empty set), but never is 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.
  • unknown is the set of all values — the top type. Everything is assignable to unknown, but unknown is assignable to almost nothing until you narrow it. It’s the type-safe version of any.
// @expect-error TS2322
let n: never;
let u: unknown = "anything goes";
let s: string = u; // ❌ unknown is too wide — narrow it first
let x: unknown = 42; // ✅ everything fits into unknown

any 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.

// @run
const withAny: any = "42";
withAny.toFixed(2); // compiles — any opts out of checking, crashes at runtime
TypeError: withAny.toFixed is not a function

any 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 TS2339
const 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
Open in TS Playground

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 runs

This 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 TS2322
type 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.

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
never, unknown, and any relative to the sets model
TypeBenefitCost
anyNo friction — every operation is allowedOpts out of the sets model entirely; the exact mistake this lesson demonstrates compiles clean and only surfaces at runtime
unknownAccepts anything, like any, but keeps every checkCannot be used for anything until narrowed — more upfront work at the boundary
neverMakes “this branch is impossible” a compile-time fact, not a commentOnly 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 checkCatches an unhandled union member the moment it’s addedRequires the default: const x: never = value boilerplate at every switch that needs the guarantee
  • any and unknown are basically the same “give up” type. They are opposites in practice: unknown keeps the sets model intact and blocks every operation until you narrow; any disables the model, which is why identical code (x.toFixed(2)) compiles under any and fails under unknown.
  • never means “an error occurred.” never means “the empty set” — no value can ever have this type. A function typed to return never isn’t broken; it’s a function that provably never returns at all (it always throws or loops).
  • An exhaustive switch needs a comment saying “this shouldn’t happen.” It needs a never-typed variable in the default branch instead — a comment can’t stop compiling when a new union member is added and left unhandled, but an assignment to never genuinely can.

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 any where it should be unknown? If nothing downstream narrows the value before using it, any will let a real mistake compile clean, exactly like withAny.toFixed(2) above.
  • Does an exhaustiveness switch actually have a never-typed default, 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 was never used 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.

In the "types as sets" model, when is type A assignable to type B?
What is the type of string & number, and why?
What set does never represent?
How does unknown differ from any?
What does tsc report for these two lines?
const withAny: any = "42";
withAny.toFixed(2);

const withUnknown: unknown = "42";
withUnknown.toFixed(2);
Does this compile, and why?
type 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;
      throw new Error(`Unhandled: ${exhaustive}`);
  }
}
A function is declared function fail(msg: string): never { throw new Error(msg); }. Why does this compile?