What you'll learn
Quick Answer
Narrowing is TypeScript reading your control flow and reducing a union type to a smaller one inside a branch. It happens automatically with typeof, instanceof, the in operator, equality checks and truthiness. Discriminated unions make it reliable by giving every variant a literal tag property. Custom type guards written with the is keyword extend it to your own shapes, and assigning the leftover value to never turns a forgotten case into a compile error instead of a runtime bug.
Narrowing is TypeScript reading your if statements
When a value has a union type, you cannot use it until you know which member you are holding. Narrowing is the compiler following your branches and working that out for you. Inside if (typeof x === "string") the type of x is string, and in the else it is everything else in the union. This is called control flow analysis, and it is one of the few places where TypeScript genuinely models JavaScript semantics rather than just labelling values.
Which is exactly why this confuses people:
function describe(value: string | { city: string } | null) {
if (typeof value === "object") {
return value.city; // Error: 'value' is possibly 'null'
}
return value.toUpperCase();
}You checked for an object and it still complains. The compiler is right and you are wrong, because in JavaScript typeof null evaluates to "object". That has been true since the first version of the language and cannot be changed without breaking the web. TypeScript models it faithfully, so after your check the type is { city: string } | null, not { city: string }.
The fix is to order the checks so the null is gone first:
function describe(value: string | { city: string } | null) {
if (value === null) return "nothing";
if (typeof value === "object") return value.city; // narrowed properly
return value.toUpperCase();
}The same category of problem applies to arrays. typeof [1, 2] is "object", so a typeof check will never distinguish an array from a plain object. Use Array.isArray, which is declared in the standard library as a type guard and therefore narrows correctly:
function first(input: string | string[]) {
if (Array.isArray(input)) return input[0]; // string[]
return input.charAt(0); // string
}Worth remembering: none of this exists at runtime. Narrowing produces no code. It only decides which operations the compiler will let you write inside a branch. If the value at runtime is not what the type says, narrowing gives you confidence and no protection.
Truthiness narrowing and the zero-marks bug
A bare if (value) narrows too, by removing everything falsy from the union. That is convenient and it is the source of a bug that shows up in almost every marks, price or count feature ever written.
function format(marks?: number) {
if (!marks) return "Not attempted";
return `${marks}/100`;
}
format(undefined); // "Not attempted" correct
format(0); // "Not attempted" wrong, the student did attempt itTypeScript is happy here because 0 is a valid number and the narrowing is technically sound. Your business logic is what is broken. The falsy set in JavaScript is false, 0, -0, 0n, "", null, undefined and NaN, and three of those are perfectly legitimate values in a form.
Check for the thing you actually mean:
function format(marks?: number) {
if (marks === undefined) return "Not attempted";
return `${marks}/100`;
}The same trap catches empty strings. A user in Pune who leaves the optional address line blank sends "", and if (!address) treats that identically to the field being absent. If those two cases mean different things in your database, you have just lost the distinction.
One loose-equality trick is worth knowing because it is idiomatic and TypeScript understands it. value != null is false for both null and undefined and true for everything else, including 0 and "". The compiler narrows on it correctly:
function label(city?: string | null) {
if (city != null) {
return city.toUpperCase(); // string, and "" still gets here
}
return "Unknown";
}This is the one place where most style guides allow != instead of !==. Equality narrowing works with literals too: comparing a variable of type "a" | "b" against "a" narrows it in both branches, and comparing two union-typed variables narrows both of them to their shared members. That last behaviour is how discriminated unions do their work.
in and instanceof: when the shape has no tag
Sometimes you are handed two object shapes with no obvious discriminator. The in operator narrows on the presence of a property:
type Student = { name: string; rollNo: string };
type Teacher = { name: string; employeeId: string; subjects: string[] };
function idOf(p: Student | Teacher) {
if ("rollNo" in p) return p.rollNo; // Student
return p.employeeId; // Teacher
}This works well when the distinguishing property is required in one member and absent from the other. It degrades quietly when the property is optional in both, because then its presence does not actually determine the variant, and TypeScript will still narrow based on the declaration. Note also that in at runtime walks the prototype chain, so an inherited property counts as present.
instanceof narrows using the prototype chain against a constructor, so it only works on things that exist at runtime: classes, Error, Date, RegExp, Map. Interfaces and type aliases have no runtime representation at all, which is why p instanceof Student where Student is an interface gives you 'Student' only refers to a type, but is being used as a value here. There is nothing to compare against; the interface was deleted during compilation.
Two further failure modes are worth naming because they are hard to debug. First, instanceof compares against a specific constructor object, so a Date created inside an iframe or a Node vm context fails instanceof Date in the parent context even though it is a real date. Second, and far more common:
const res = await fetch("/api/orders/1");
const order = (await res.json()) as { id: string; placedAt: Date };
order.placedAt instanceof Date; // false at runtime
order.placedAt.getFullYear(); // compiles, throwsJSON has no date type, so placedAt is a string. The as assertion told the compiler otherwise and the compiler believed you without checking. This is the single most important thing to internalise about narrowing: it reasons about the types you declared, and an assertion at an API boundary makes every downstream narrowing meaningless. Validate data where it enters the program, then narrow freely inside.
Discriminated unions: the pattern that makes narrowing free
The reliable way to model "one of several shapes" is to give every member a property whose type is a distinct literal. TypeScript then narrows the entire object from a single check, with no guard functions and no property sniffing.
type Result =
| { status: "loading" }
| { status: "success"; data: string[] }
| { status: "error"; message: string };
function render(r: Result): string {
switch (r.status) {
case "loading": return "Loading...";
case "success": return r.data.join(", ");
case "error": return r.message;
}
}Inside case "success", r.data is a string[] and r.message is a compile error. Compare that with the shape most people write first:
type BadResult = {
loading: boolean;
data?: string[];
error?: string;
};This type permits states that cannot exist: loading and error at the same time, success with no data, error with no message. Every read then needs data! or a defensive check, and the non-null assertion is where the crashes come from. The union version makes the impossible states unrepresentable, which is worth more than any amount of runtime validation added later.
Two rules make or break the pattern. The tag must be a literal type: writing status: string in even one member destroys narrowing for the whole union, because a plain string could equal any of the literals. And the tag property must have the same name in every member. Mixing status in one variant with type in another gives you a union that only the in operator can split.
The tag does not have to be a string. Booleans work and are common in result types:
type Parsed<T> =
| { ok: true; value: T }
| { ok: false; error: string };
function use(p: Parsed<number>) {
if (p.ok) return p.value + 1; // number
return p.error.length; // string
}This is the shape you want for anything that can fail: parsing a form field, reading a config value, calling an API. It forces the caller to check ok before touching the value, and the compiler enforces it at every call site rather than relying on a comment.
Custom guards, assertions and the never check
When built-in narrowing is not enough, you write a type predicate. The is return type tells the compiler that a true result means the argument has that type.
function isStudent(p: Student | Teacher): p is Student {
return "rollNo" in p;
}
declare function load(): (Student | Teacher)[];
const people: (Student | Teacher)[] = load();
const students = people.filter(isStudent); // Student[]The filter line is the real payoff: without the predicate you get (Student | Teacher)[] back and have to cast. But understand the deal you just made. TypeScript does not verify that the body of a type guard actually checks what it claims. return true compiles fine as the body of isStudent. A guard is an assertion with a nicer syntax, and a wrong one is a lie the compiler will happily propagate through the rest of the file.
Assertion functions are the throwing variant, useful for invariants:
function assertDefined<T>(v: T, name: string): asserts v is NonNullable<T> {
if (v == null) throw new Error(`${name} is missing`);
}
const el = document.getElementById("root");
assertDefined(el, "root element");
el.classList.add("ready"); // el is HTMLElement hereOne rule catches everyone: assertion functions must be called through a name that has an explicit type annotation. Assigning one to a plain const arrow function without annotating it produces Assertions require every name in the call target to be declared with an explicit type annotation. Use a function declaration and the problem disappears.
Finally, the exhaustiveness check. Assign the value in the default branch to never:
function render(r: Result): string {
switch (r.status) {
case "loading": return "Loading...";
case "success": return r.data.join(", ");
case "error": return r.message;
default: {
const exhaustive: never = r;
throw new Error(`Unhandled status: ${JSON.stringify(exhaustive)}`);
}
}
}If every case is handled, r in the default branch has type never and the assignment is legal. Add a fourth member to Result six months later and this file fails to compile with Type '{ status: "cancelled" }' is not assignable to type 'never'. That is the whole trick: it converts a forgotten case from a blank screen in production into a build failure. It only works if you do not add a catch-all default that returns a fallback value, which is precisely the habit it is meant to replace.
