GoByte Skills Episode 13 of 27, track React with TypeScript (1 of 3)

TypeScript narrowing: typeof is the bouncer

GoByte Skills #13: typeof does not just check a type, it proves one. After the check each branch gets a narrower type and the compiler holds you to it. A handwritten type guard only promises.

Arthur is one of GoByte's characters. This post was drafted by AI agents in Arthur's voice, then fact checked, run and edited by the GoByte team.

Back to top

Narrowing is control flow analysis, so the check is the proof and a type guard is only a promise. A union says you do not know yet which case you hold. A runtime check is how you find out, and the compiler reads that check.

Animation for GoByte Skills #13: One union splits into two typed lanes.
Transcript

TypeScript code types in while a value of type string or number reaches a typeof gate and splits into a cyan string lane and an orange number lane, then the string chip tries toFixed and the real compiler error appears, showing that each branch only gets the methods its narrowed type has.

function pad(x: string | number): string {
  if (typeof x === "string") {
    return x.padStart(4, "0"); // x: string
  }
  return x.toFixed(2); // x: number
}

console.log(pad("7"), pad(3.14159));
$ tsc --strict --target es2017 pad.ts && node pad.js
0007 3.14

Target ES2017 or later: padStart is an ES2017 method, and plain tsc --strict pad.ts (default target) rejects it with TS2550 on TypeScript 5.9; newer releases default to a newer target.

Why it works#

The compiler follows every path through the function and tracks the type of x at each point. Inside the if, only string passes the check. After the return, that path is gone, so string is subtracted and number is left. No as anywhere: the check is the evidence. Move toFixed into the if and the build fails with the real message: error TS2551: Property 'toFixed' does not exist on type 'string'. Did you mean 'fixed'?

What narrows#

typeof, instanceof, in, ===, truthiness, and a discriminant like s.kind === "circle". All are real runtime checks. The emitted pad.js is the same function minus the annotations: the typeof stays, the types vanish.

A guard is a promise#

A function returning x is string tells the compiler what it proves, and the compiler believes it without reading the body:

// compiles, and lies
const isString = (x: unknown): x is string => true;

Give it const v: unknown = 42 and inside if (isString(v)) the compiler calls v a string, so v.toUpperCase() compiles and then throws TypeError: v.toUpperCase is not a function. Since TypeScript 5.5, a plain arrow like (x: unknown) => typeof x === "string" gets its predicate inferred from the body, so filter returns string[] (5.4 gave (string | number)[]). That guard is backed by a check. A handwritten one is backed by your word.

Rule of thumb#

Prefer checks the compiler can see (typeof, in, a discriminant) over as and over handwritten guards. When a union grows, a switch whose default assigns the value to never fails with TS2322 at every place that forgot the newcomer. A cast says "trust me". A check shows its ID at the door, and the bouncer reads it.

Report a mistake

Your product here? Partner with us

Back to top

Discussion

No comments yet. Signed in GoByte members with a verified e-mail can join. Community guidelines

Reading is open to everyone. Commenting and voting need a GoByte account with a verified e-mail.