Skip to content

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.

LessonWhat you’ll learn
Literals & wideningLiteral types, when they widen, and as const
Unions & intersectionsA | B (wider) vs A & B (narrower), and how to work with each
Objects, arrays & tuplesOptional/readonly properties, index signatures, tuples
FunctionsOverloads, this typing, rest params, the void nuance
NarrowingTurning a union into a specific type with control flow
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"]
Everyday typing is describe then narrow

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.

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.

// @run
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" }));
// GET /users
// OK: {"id":1}
// Error 404: not found

Describe, 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" }));
Open in TS Playground
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"]
Every lesson in this module is one stage of the same pipeline

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.

ApproachBenefitCost
Loose types (string, object, any)Fast to write, no upfront modelingNo autocomplete, no exhaustiveness, bugs surface at runtime instead of compile time
Literal unions + discriminated unionsCompiler 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 shapesSelf-documenting, checked at every call siteExtra ceremony for small, one-off objects
Narrowing with control flowZero runtime cost, reads like normal JSOnly as good as the check you write — narrowing limits (later lessons) can lull you into false confidence
  • Describing a type is the safety net. A type only protects you at the point where the compiler can still see it — the switch/if that narrows a union at the moment you use it is what actually catches bugs.
  • A wider type is always the safer choice. Using string instead of a literal union removes the compiler’s ability to catch a typo like "colour" — it just moves the bug to production.
  • interface and type are 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.

Before trusting a generated function over a union type, check for:

  • A switch over a discriminated union with no default: never guard — it will silently stop being exhaustive the day a new case is added.
  • A function whose switch/if chain doesn’t cover every branch with a return — the inferred return type quietly grows an extra | undefined that only shows up as an error later, at the call site.
  • A parameter typed string or object where 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.

What is the recurring rhythm of everyday TypeScript?
Which underlying model ties this whole module together?
Why does area(s).toFixed(2) fail to compile even though every Shape case is handled by an if?
function area(s: Shape) {
  if (s.kind === "circle") return Math.PI * s.radius ** 2;
  if (s.kind === "square") return s.side ** 2;
}
What happens if a new { kind: "triangle"; ... } member is added to Shape but no new case is added?
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number };

function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.radius ** 2;
    case "square": return s.side ** 2;
    default:
      const _exhaustive: never = s;
      return _exhaustive;
  }
}
Why is a loose string parameter usually a worse choice than a literal union like "GET" | "POST"?