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 it

TypeScript 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, throws

JSON 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 here

One 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.

Frequently Asked Questions

Why does my narrowing disappear inside a callback? TypeScript resets narrowing for variables declared with let or var when they are used inside a function that could run later, because the value may have been reassigned in between. It cannot prove otherwise. Two fixes work: declare the variable with const, or copy the narrowed value into a new const inside the branch and use that const in the callback.
Is using as the same as narrowing? No, and the difference matters. Narrowing is the compiler proving something from your code. An as assertion is you overriding the compiler with no check performed at all. Assertions are appropriate only when you genuinely know more than the type system, such as immediately after a runtime validation. Using as on JSON.parse output is the most common way to make every downstream type in the program untrustworthy.
Does narrowing work on object properties, not just variables? Yes. Writing if (user.address) narrows user.address for the rest of the block, so user.address.city is allowed. The narrowing persists until you assign to that property. It does not reset because you called some other function, so if that function mutates the object the compiler will not catch it. This is a deliberate trade-off for usability, not a guarantee.
When should I use typeof versus instanceof versus in? Use typeof for primitives such as string, number, boolean and function, remembering that null and arrays both report object. Use instanceof for values created with a constructor, such as Error, Date or your own classes. Use the in operator for plain object shapes that have no constructor. If you control the types, add a literal tag property and use a discriminated union instead of all three.
What is the difference between never and unknown? unknown is the type of a value that could be anything, so you must narrow it before doing anything with it. never is the type with no possible values, produced when narrowing eliminates every option or when a function never returns because it always throws or loops forever. unknown is the safe top of the type system and never is the bottom, which is why the exhaustiveness check assigns to never.