Narrowing with typeof, in and truthiness
Narrowing is the mechanism that makes unions usable: you write an ordinary JavaScript check, and TypeScript follows your reasoning, narrowing a value's type inside the branch where the check holds. It is one of the cleverest and most pleasant things about the language — the compiler tracks what you have proven. This lesson is the full set of ways to narrow, which you will use in almost every function that touches a union.
typeof — narrowing by primitive type
The most basic narrowing, for primitives:
function format(value: string | number): string {
if (typeof value === "string") {
return value.toUpperCase(); // value narrowed to string
} else {
return value.toFixed(2); // value narrowed to number
}
}
typeof value === "string" narrows to string in the if; TypeScript knows the else must be
number. The typeof operator returns one of a fixed set of strings — "string", "number",
"boolean", "object", "function", "undefined", "symbol", "bigint" — and TypeScript
understands each as a narrowing. This is your tool for unions of primitives.
One famous JavaScript gotcha typeof carries: typeof null === "object". So typeof x === "object"
narrows to the object types and null — a real trap the truthiness check below handles better.
Truthiness — narrowing out null and undefined
A plain if (value) narrows away the "falsy" values — null, undefined, "", 0, false, NaN:
function greet(name: string | null | undefined): string {
if (name) {
return `Namaste, ${name}`; // name narrowed to string (null/undefined ruled out)
}
return "Namaste, guest";
}
if (name) is truthy only when name is a non-empty string, so inside the branch TypeScript narrows
name to string (removing null and undefined). This is the most common way to handle a
possibly-absent value. But mind the JavaScript trap it shares: for a string | undefined, if (name)
also excludes the empty string "" — which is sometimes a valid value you did not mean to treat as
absent. When the difference between "absent" and "empty" matters, check explicitly:
if (name != null) { // narrows out null AND undefined, but KEEPS "" and 0
// name is string here, empty string included
}
x != null (with the loose !=) is a handy idiom: it removes both null and undefined while
keeping other falsy values like "" and 0. Reach for it when empty and zero are legitimate.
Equality — narrowing by comparing values
Comparing a value against a literal narrows it:
type Direction = "north" | "south" | "east" | "west";
function move(dir: Direction) {
if (dir === "north") {
// dir narrowed to "north" here
}
}
function check(a: string | number, b: string | boolean) {
if (a === b) {
// a and b must both be the shared type: string. Both narrowed to string here.
}
}
=== against a literal narrows to that literal. And comparing two unions with === narrows both to
their common type — if a === b can only be true when both are strings, TypeScript narrows both to
string in the branch. This is how a switch over a literal union works (the exhaustiveness lesson),
and it is the discriminant check that powers discriminated unions.
in — narrowing by property presence
For object unions, checking whether a property exists narrows to the member that has it:
type Admin = { role: "admin"; permissions: string[] };
type User = { role: "user"; lastLogin: Date };
function describe(account: Admin | User): string {
if ("permissions" in account) {
return `Admin with ${account.permissions.length} permissions`; // narrowed to Admin
}
return `User, last seen ${account.lastLogin}`; // narrowed to User
}
"permissions" in account is true only for Admin, so inside the branch TypeScript narrows to
Admin and account.permissions is available. in is useful when object members differ by which
properties they have. (When they share a common tag property, the discriminated-union pattern —
checking account.role — is cleaner; in is for when there is no such tag.)
instanceof — narrowing by class
For values that might be instances of classes, instanceof narrows:
function handle(value: Date | string): string {
if (value instanceof Date) {
return value.toISOString(); // narrowed to Date — Date methods available
}
return value.toUpperCase(); // narrowed to string
}
value instanceof Date narrows to Date. This works for any class (built-in like Date, Error,
Array, or your own), and is the right tool when your union includes class instances.
Narrowing flows through your code
The clever part: TypeScript tracks narrowing across statements, not just inside one if. The
early-return (or "guard clause") pattern uses this to flatten code:
function process(value: string | null): string {
if (value === null) {
return "no value";
}
// from here on, TypeScript knows value is string — for the rest of the function
return value.toUpperCase();
}
After if (value === null) return, TypeScript knows value cannot be null for the rest of the
function — so the whole body below works with a narrowed string, no nesting. This "handle the special
cases at the top, then work with the narrowed value" shape is idiomatic and readable, and it is exactly
the same pattern good code uses in every language — TypeScript just makes it type-safe.
One caveat: narrowing can be lost. If you narrow a value and then call a function (which might, in
principle, change it), or reassign it, TypeScript may widen it back. For a let variable modified
between the check and the use, or a property accessed through something the compiler cannot prove is
stable, the narrowing may not hold — copying into a const local usually fixes it. This is rare but
worth recognising when a narrowing "does not stick".
Check your work
What narrowing is. Writing an ordinary check that TypeScript follows, narrowing a value's type inside the branch where the check holds.
typeof narrowing. For primitives — typeof x === "string" narrows to string; note the trap
typeof null === "object".
Truthiness narrowing. if (value) removes falsy values (null, undefined, "", 0, ...); use
x != null to remove only null/undefined and keep ""/0.
Equality narrowing. === against a literal narrows to it; comparing two unions narrows both to
their common type.
in narrowing. Checking a property's presence narrows to the object member that has it — for
untagged object unions.
instanceof narrowing. Narrows to a class instance — for unions including class instances.
The early-return pattern. Handle special cases at the top with return; the narrowed type flows
through the rest of the function.
When narrowing is lost. After a reassignment or an intervening call the compiler cannot prove is
safe — copy into a const to restore it.
Practice
- Narrow a
string | numberwithtypeofand use a type-specific method in each branch. - Narrow a
string | null | undefinedwithif (value). Then test with an empty string and note it is treated as absent. Switch tovalue != nulland confirm""survives. - Narrow a literal union with
===and act differently per literal. - Narrow an untagged object union (
Admin | Userwith different properties) usingin. - Narrow a
Date | stringunion withinstanceof. - Use the early-return pattern to handle a
nullat the top, then work with the narrowed value below. - Narrow a
letvariable, reassign it, and observe the narrowing is lost. Fix it with aconst.
Official documentation
- TypeScript — Narrowing — Every narrowing technique in one place.
- TypeScript — typeof type guards — And the
typeof nulltrap. - TypeScript — Truthiness narrowing — And its falsy caveats.
- TypeScript — instanceof and in narrowing — For classes and property checks.
Next: discriminated unions — the best pattern in the language.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship