TypeScript Narrowing - The Rules Every Dev Should Know
Narrowing is the bit of TypeScript that turns a wide type into a more specific one based on what your code is checking. It’s the reason you can write if (typeof x === "string") and have TS know, inside the block, that x is now a string. It feels like magic the first few times. It stops feeling like magic the moment it doesn’t work and you can’t figure out why.
This post is the set of rules I wish someone had handed me three years ago. The official reference at typescriptlang.org/docs/handbook/2/narrowing goes wider, but if you remember just these patterns, you’ll handle 95% of real code.
Rule 1: typeof works for primitives
The simplest case. typeof narrows to "string", "number", "boolean", "object", "function", "symbol", "bigint", or "undefined".
function format(input: string | number) {
if (typeof input === "string") {
return input.toUpperCase(); // input is string here
}
return input.toFixed(2); // input is number here
}
The trap: typeof null === "object". So typeof x === "object" does not narrow away null. You have to check for null separately, which leads us to -
Rule 2: Truthiness narrows away null and undefined
function greet(name: string | null | undefined) {
if (name) {
console.log(name.toUpperCase()); // name is string here
}
}
Be careful with empty strings and zero - they’re falsy. If your value is string | null and the empty string is meaningful, use name != null (note: !=, not !==) which is the one case where the loose equality is genuinely useful: it narrows to “not null and not undefined”.
Rule 3: Equality narrows literal types
function direction(d: "up" | "down" | "left" | "right") {
if (d === "up" || d === "down") {
// d is "up" | "down" here
} else {
// d is "left" | "right" here
}
}
This is the foundation that makes the next rule - discriminated unions - work.
Rule 4: Discriminated unions are the workhorse
This is the pattern I use most often. Each variant of a union type carries a literal “tag” property, and you switch on that tag.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "rectangle"; width: 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;
case "rectangle":
return shape.width * shape.height;
}
}
Inside each case, TypeScript narrows shape to just that variant. You get autocomplete on radius, side, width - the right one for each branch.
The trick is to pick a property name (kind, type, _tag, whatever) and stick to it. Once you’re in the habit, your domain types model themselves.
Rule 5: The in operator for objects without a tag
Sometimes you don’t control the shape of the data - you can’t add a kind field to a third-party type. Use in to check for property presence:
type Cat = { meow(): void };
type Dog = { bark(): void };
function speak(animal: Cat | Dog) {
if ("meow" in animal) {
animal.meow(); // animal is Cat
} else {
animal.bark(); // animal is Dog
}
}
This is rougher than discriminated unions because it relies on structural differences. If two variants share the same property names but different types, in won’t help.
Rule 6: Custom type guards (x is T)
When the check is more complex than typeof or in, write a function that returns x is T:
type ApiSuccess = { ok: true; data: unknown };
type ApiError = { ok: false; error: string };
type ApiResponse = ApiSuccess | ApiError;
function isSuccess(res: ApiResponse): res is ApiSuccess {
return res.ok === true;
}
function handle(res: ApiResponse) {
if (isSuccess(res)) {
console.log(res.data); // narrowed to ApiSuccess
} else {
console.error(res.error); // narrowed to ApiError
}
}
The res is ApiSuccess return annotation is what tells TypeScript “if this returns true, narrow accordingly”. Without it you’d just get boolean and no narrowing.
I adopted this after my colleague Ash introduced it in our codebase, makes life so simpler.
This pattern is also the right answer when validating JSON from an API. Pair it with a runtime validator (zod, valibot) and you have type-safe boundaries.
Rule 7: Exhaustiveness with never
The biggest payoff of discriminated unions is exhaustiveness checking. If you add a new variant later, TypeScript can fail your build if you forgot to handle it.
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;
throw new Error(`Unhandled shape: ${(_exhaustive as Shape).kind}`);
}
}
If someone adds { kind: "triangle"; base: number; height: number } to Shape and doesn’t update this switch, the assignment const _exhaustive: never = shape will fail to compile because shape is no longer narrowed to never - it’s narrowed to the unhandled triangle variant.
This is the closest thing TypeScript has to a “compiler-enforced safety net” against forgotten cases. I treat it as load-bearing in any non-trivial domain modelling.
Rule 8: Narrowing doesn’t cross function calls
This one trips people up. Once you call a function, TypeScript forgets what it knew.
type User = { name: string; email: string | null };
function send(user: User) {
if (user.email) {
log("about to send"); // any function call here...
sendEmail(user.email); // ...resets the narrowing!
// user.email is now string | null again
}
}
The fix is to assign to a const:
function send(user: User) {
const email = user.email;
if (email) {
log("about to send");
sendEmail(email); // email is string here, regardless of side-effects
}
}
TypeScript can’t know whether log() mutated user.email, so it conservatively widens. Local consts are immutable references - narrowing on them is sticky.
Rule 9: Generic narrowing is unreliable
Inside a generic function, TypeScript often can’t narrow the way you’d expect.
function isString<T>(x: T): boolean {
return typeof x === "string";
}
function example<T>(x: T) {
if (isString(x)) {
// x is still T here, NOT string
}
}
For generics, you almost always need an explicit type guard signature or a redesign. This is one of the few places where TypeScript’s local inference falls short of what you’d hope for.
Closing
Most narrowing problems I see in code reviews come down to one of these:
- Treating
typeof x === "object"as “not null” (it isn’t). - Reaching for
asinstead of writing a type guard. - Skipping the
default: neverexhaustiveness check. - Forgetting that function calls reset narrowing.
Once these become reflexes, the type system stops feeling like an obstacle and starts feeling like a co-author. It still won’t catch every bug - but it’ll catch the boring ones, which is what matters most when you’re tired.