Everyday Types, Deep
The types you already use — properly understood
Section titled “The types you already use — properly understood”You reach for these every day: string literals, unions, object shapes, tuples, functions. This module slows down on the parts that trip people up — widening, as const, the difference between a union and an intersection, and the control-flow narrowing that makes unions actually usable.
Everything here rests on the foundations model: a type is a set of values. Keep that lens and each feature is just a way of describing or shrinking a set.
What this module covers
Section titled “What this module covers”| Lesson | What you’ll learn |
|---|---|
| Literals & widening | Literal types, when they widen, and as const |
| Unions & intersections | A | B (wider) vs A & B (narrower), and how to work with each |
| Objects, arrays & tuples | Optional/readonly properties, index signatures, tuples |
| Functions | Overloads, this typing, rest params, the void nuance |
| Narrowing | Turning a union into a specific type with control flow |
The through-line: describe, then narrow
Section titled “The through-line: describe, then narrow”flowchart LR describe["Describe the shape (literals, unions, objects)"] --> hold["Hold a wide type (a union of possibilities)"] hold --> narrow["Narrow with control flow (typeof, in, discriminants)"] narrow --> use["Use the specific type safely"]
You describe data with unions and object types, which leaves you holding a wide type (several possibilities). Then you narrow — with typeof, in, a discriminant field — down to the one specific case, and TypeScript lets you use it safely. Master that loop and you’ve mastered day-to-day TypeScript.
Putting it together
Section titled “Putting it together”Every lesson in this module shows up in one realistic shape: a literal union for the allowed values, an object type for the request, a discriminated union for the response, and a switch that narrows it.
// @runtype Method = "GET" | "POST"; // literal union -- describe
interface ApiRequest { readonly method: Method; path: string;}
type ApiResponse = | { kind: "ok"; status: 200; data: unknown } | { kind: "error"; status: 400 | 404 | 500; message: string };
function describe(res: ApiResponse): string { switch (res.kind) { case "ok": return `OK: ${JSON.stringify(res.data)}`; case "error": return `Error ${res.status}: ${res.message}`; default: { const _exhaustive: never = res; // exhaustiveness -- a new kind fails to compile here return _exhaustive; } }}
const req: ApiRequest = { method: "GET", path: "/users" };console.log(req.method, req.path);console.log(describe({ kind: "ok", status: 200, data: { id: 1 } }));console.log(describe({ kind: "error", status: 404, message: "not found" }));// GET /users// OK: {"id":1}// Error 404: not foundDescribe, then narrow
type Method = "GET" | "POST"; // literal union -- describe
interface ApiRequest { readonly method: Method; path: string;}
type ApiResponse = | { kind: "ok"; status: 200; data: unknown } | { kind: "error"; status: 400 | 404 | 500; message: string };
function describe(res: ApiResponse): string { switch (res.kind) { case "ok": return `OK: ${JSON.stringify(res.data)}`; case "error": return `Error ${res.status}: ${res.message}`; default: { const _exhaustive: never = res; // exhaustiveness -- a new kind fails to compile here return _exhaustive; } }}
const req: ApiRequest = { method: "GET", path: "/users" };console.log(req.method, req.path);
console.log(describe({ kind: "ok", status: 200, data: { id: 1 } }));console.log(describe({ kind: "error", status: 404, message: "not found" })); Mental Model
Section titled “Mental Model”flowchart LR lit["Literals & widening pin down exact values"] --> ui["Unions & intersections combine (OR) or compose (AND)"] ui --> oat["Objects, arrays & tuples shape the data"] oat --> fn["Functions shape the behavior"] fn --> nar["Narrowing collapse the union at use-time"]
Treat the five lessons as one pipeline, not five unrelated topics: you pin down exact values with literals, combine or compose them with unions and intersections, shape data with objects, arrays and tuples, shape behavior with functions, and finally collapse everything back down with narrowing at the point where you actually use the value. Skipping straight to any or a loose string at any stage breaks the chain — the payoff of the whole module only shows up once every stage is precise and the final switch/if narrows to something real.
Trade-offs
Section titled “Trade-offs”| Approach | Benefit | Cost |
|---|---|---|
Loose types (string, object, any) | Fast to write, no upfront modeling | No autocomplete, no exhaustiveness, bugs surface at runtime instead of compile time |
| Literal unions + discriminated unions | Compiler enforces every case is handled (never exhaustiveness) | More upfront type declarations; adding a new case is a deliberate, visible edit everywhere it’s used |
interface/type object shapes | Self-documenting, checked at every call site | Extra ceremony for small, one-off objects |
| Narrowing with control flow | Zero runtime cost, reads like normal JS | Only as good as the check you write — narrowing limits (later lessons) can lull you into false confidence |
Common Misconceptions
Section titled “Common Misconceptions”- Describing a type is the safety net. A type only protects you at the point where the compiler can still see it — the
switch/ifthat narrows a union at the moment you use it is what actually catches bugs. - A wider type is always the safer choice. Using
stringinstead of a literal union removes the compiler’s ability to catch a typo like"colour"— it just moves the bug to production. interfaceandtypeare meaningfully different for a plain object. For an object shape they’re close to interchangeable; the difference only matters for declaration merging, unions, or mapped types.
Reading AI-generated code
Section titled “Reading AI-generated code”Before trusting a generated function over a union type, check for:
- A
switchover a discriminated union with nodefault: neverguard — it will silently stop being exhaustive the day a new case is added. - A function whose
switch/ifchain doesn’t cover every branch with areturn— the inferred return type quietly grows an extra| undefinedthat only shows up as an error later, at the call site. - A parameter typed
stringorobjectwhere a literal union or a specific interface was available — a sign the author didn’t model the actual allowed values.
🐛 Spot the bug
type Shape =| { kind: "circle"; radius: number }| { kind: "square"; side: number };
function area(s: Shape) {if (s.kind === "circle") return Math.PI * s.radius ** 2;if (s.kind === "square") return s.side ** 2;}
const s: Shape = { kind: "circle", radius: 2 };console.log(area(s).toFixed(2));Show the answer
Neither if is written as an exhaustive switch, so TypeScript can’t prove every Shape returns a number — the inferred return type of area is number | undefined. The real diagnostic on area(s).toFixed(2) is TS2532: Object is possibly 'undefined'. The fix is the discriminated-union pattern from the narrowing lesson: a switch on kind with a default branch that assigns to never, so a missing case is a compile error instead of a silent undefined.