Type Narrowing in TypeScript: Giving the Compiler the Right Clues
3 min read
Early TypeScript frustration has a signature sound: "I know this is a string here, why is the compiler yelling?" The answer is almost always narrowing — or rather, not giving the compiler the evidence it needs to narrow. Once you see how narrowing works, the relationship flips: instead of fighting the checker, you feed it clues and it does the paranoid bookkeeping for you.
TypeScript is smarter than you think
The compiler doesn't just check declared types; it tracks types through control flow.
Inside an if that eliminates possibilities, the type literally changes:
function describe(value: string | number) {
// here: value is string | number
if (typeof value === 'string') {
// here: value is string — .toUpperCase() is fine
return value.toUpperCase();
}
// here: value is number — TS knows string was handled above
return value.toFixed(2);
}No casts, no assertions. The typeof check isn't just runtime code — it's evidence
the compiler reads. Narrowing is the art of writing checks the compiler recognizes as
evidence. Three cover nearly everything.
Clue 1: typeof — for primitives
typeof narrows among primitives: 'string', 'number', 'boolean', 'undefined',
'object', 'function'. It's the tool for "this argument can be several basic things":
function toDate(input: string | number | Date) {
if (typeof input === 'string') return new Date(input); // string
if (typeof input === 'number') return new Date(input); // number (timestamp)
return input; // all that's left: Date
}Note the last line: no check needed. Elimination is narrowing — TypeScript knows what
remains. The everyday special case is truthiness for null/undefined:
if (!user) return; narrows User | undefined to User for the rest of the function —
the pattern strict mode makes you
internalize.
Clue 2: instanceof — for class instances
typeof calls every object 'object', so for class instances you need instanceof:
function logError(err: unknown) {
if (err instanceof Error) {
console.error(err.message, err.stack); // narrowed to Error
return;
}
console.error('Non-error thrown:', err);
}This exact function appears in some form in every codebase I work on, because catch
gives you unknown — and instanceof Error is the sanctioned way back to something
useful. Same tool for x instanceof Date, x instanceof Response, and your own
classes.
Clue 3: A shared discriminant property — for object shapes
Plain object types (API payloads, state objects) have no class to instanceof and are
all 'object' to typeof. The pattern: give every variant a shared literal property,
then narrow on it:
type PaymentMethod =
| { kind: 'card'; last4: string }
| { kind: 'bank'; iban: string }
| { kind: 'paypal'; email: string };
function label(method: PaymentMethod): string {
switch (method.kind) {
case 'card':
return `Card ending ${method.last4}`; // TS knows last4 exists here
case 'bank':
return `Bank ${method.iban.slice(0, 4)}…`;
case 'paypal':
return `PayPal (${method.email})`;
}
}Checking one property teaches the compiler the whole shape. This is the discriminated union pattern — simple here, and powerful enough that it gets its own post.
Putting it together
Real functions often need more than one clue. A parser accepting several input forms:
type RawEvent = string | Date | { kind: 'scheduled'; at: string } | null;
function eventDate(input: RawEvent): Date | undefined {
if (!input) return undefined; // eliminates null
if (typeof input === 'string') return new Date(input); // string handled
if (input instanceof Date) return input; // Date handled
return new Date(input.at); // only the object remains —
} // .at is fully typedFour input shapes, zero casts, and every branch's body is checked against exactly the
type that can reach it. Delete the instanceof line and the last line errors —
the compiler is genuinely tracking the elimination.
The takeaway
When TypeScript "doesn't believe you," it's telling you your certainty exists in your
head but not in the code. Don't reach for as — that silences the checker precisely
where you need it. Write the check: typeof for primitives, instanceof for class
instances, a discriminant property for object shapes. The checks are code you should
have anyway; TypeScript just rewards you for writing them.