What narrowing is

A union type such as string | number only lets you use what both members have in common. To do something specific, you check which member you have, with ordinary JavaScript. TypeScript follows that check: inside the branch where it is true, the variable has the narrower type. That is narrowing.

function formatKWh(value: string | number): string {
  if (typeof value === "string") {
    // here value is string
    return `${parseFloat(value).toFixed(1)} kWh (parsed)`;
  }
  // here value is number
  return `${value.toFixed(1)} kWh`;
}

console.log(formatKWh(4.2));
console.log(formatKWh("12.04"));
4.2 kWh
12.0 kWh (parsed)

Nothing new is written for the compiler; the typeof check is plain JavaScript and stays in the output. What makes it work is control flow analysis: the compiler follows every path through the function (each if, else, return, throw and switch case) and tracks which types a variable can still have at each point. After the if block returns, only number is left, so value.toFixed is allowed without an else.

Control flow analysis. At the top, value has the type string or number. The check typeof value equals "string" splits the flow. On the true branch, value is string, and that branch returns. On the false branch, value is number. After the if statement, only the false branch continues, so value is number there. value: string | number typeof value === "string" ? value: string value: number true false return … (path ends) after the if: value: number
Each branch sees a narrower type. Because the true branch returns, only the number path reaches the code after the if.

The check has to make sense, too. Inside the branch, the compiler holds you to the narrowed type:

function formatKWh(value: string | number): string {
  if (typeof value === "string") {
    return value;
  }
  return value.toUpperCase();
}
example.ts(5,16): error TS2339: Property 'toUpperCase' does not exist on type 'number'.

An expression that narrows a type in this way is called a type guard. The rest of this page walks through the built-in ones, then shows how to write your own.

typeof, truthiness and equality

typeof

The typeof operator returns a string such as "string", "number", "boolean", "undefined", "object" or "function". TypeScript understands each of these results, also in a switch:

function describe(value: string | number | boolean | null | undefined): string {
  switch (typeof value) {
    case "string":
      return `text "${value.trim()}"`;
    case "number":
      return `number ${value.toFixed(2)}`;
    case "boolean":
      return value ? "yes" : "no";
    default:
      return `nothing (${value})`;
  }
}

for (const v of [" E-1001 ", 4.2, true, null, undefined]) {
  console.log(describe(v));
}
text "E-1001"
number 4.20
yes
nothing (null)
nothing (undefined)

One JavaScript quirk carries over: typeof null is "object". A typeof x === "object" check therefore narrows to "object or null", and TypeScript will remind you to rule out null as well.

Truthiness

if (note) narrows away null and undefined, because both are falsy. It is short and common, but it also rejects the other falsy values: "", 0 and NaN. For strings that is often fine. For numbers it is a real bug, because 0 is a valid reading:

function kWhText(kWh: number | undefined): string {
  if (!kWh) {
    return "no reading";
  }
  return `${kWh} kWh`;
}
console.log(kWhText(4.2));
console.log(kWhText(undefined));
console.log(kWhText(0));
4.2 kWh
no reading
no reading

Equality

Comparing with === or !== narrows as well. Testing against undefined exactly is the precise version of the check above, and it keeps the 0:

function kWhText(kWh: number | undefined): string {
  if (kWh === undefined) {
    return "no reading";
  }
  return `${kWh} kWh`;
}
console.log(kWhText(4.2));
console.log(kWhText(undefined));
console.log(kWhText(0));
4.2 kWh
no reading
0 kWh

Comparing with a literal (status === "open") narrows a literal union to that one value, which is the basis of discriminated unions below. The loose comparison x == null is the one accepted use of == in strict code: it is true for both null and undefined, and TypeScript narrows both away.

instanceof and in

instanceof

For values created by a class (including built-ins such as Date, Map and Error), x instanceof C checks the prototype chain at run time, and TypeScript narrows to C:

function when(at: string | Date): string {
  if (at instanceof Date) {
    return at.toISOString().slice(0, 10);
  }
  return at.slice(0, 10);
}

console.log(when(new Date(Date.UTC(2026, 9, 5))));
console.log(when("2026-10-06T08:15:00Z"));
2026-10-05
2026-10-06

The place you will use instanceof most is a catch block. Anything can be thrown in JavaScript, so in strict mode the caught value has the type unknown, and you must narrow it before reading .message:

try {
  JSON.parse("{ broken");
} catch (err) {
  console.log(err.message);
}
example.ts(4,15): error TS18046: 'err' is of type 'unknown'.
function parseKWh(text: string): number {
  const value = Number(text);
  if (Number.isNaN(value)) {
    throw new RangeError(`not a number: "${text}"`);
  }
  return value;
}

for (const input of ["4.2", "four"]) {
  try {
    console.log(parseKWh(input));
  } catch (err) {
    if (err instanceof RangeError) {
      console.log(`Rejected: ${err.message}`);
    } else {
      throw err;
    }
  }
}
4.2
Rejected: not a number: "four"

Catching only the errors you expect and rethrowing the rest is the pattern from Error Handling; TypeScript just makes the check mandatory.

in

Interfaces and object types have no run-time existence, so instanceof cannot test for them. What you can test is whether a property exists: "homes" in e. TypeScript narrows a union of object types to the members that have (or may have) that property:

interface Outage {
  ticket: string;
  homes: number;
}
interface Reading {
  meterId: string;
  kWh: number;
}

function describe(e: Outage | Reading): string {
  if ("homes" in e) {
    return `${e.ticket}: ${e.homes} homes without power`;
  }
  return `${e.meterId}: ${e.kWh} kWh`;
}

console.log(describe({ ticket: "INC-30418", homes: 120 }));
console.log(describe({ meterId: "E-1001", kWh: 4.2 }));
INC-30418: 120 homes without power
E-1001: 4.2 kWh

This works, but it is fragile: add a homes property to Reading one day and the check silently means something else. When you design the types yourself, a dedicated tag property is clearer.

Discriminated unions

A discriminated union (also called a tagged union) is a union of object types that all share one property with a different literal type in each member, usually named kind, type or status. Checking that one property narrows the whole object to the matching member. It is the standard TypeScript way to model "one of several kinds of thing", such as events, messages, API results and state machines:

type Event =
  | { kind: "outage"; ticket: string; homes: number }
  | { kind: "reading"; meterId: string; kWh: number }
  | { kind: "tariff-change"; customer: string; from: string; to: string };

function describe(e: Event): string {
  switch (e.kind) {
    case "outage":
      return `${e.ticket}: ${e.homes} homes without power`;
    case "reading":
      return `${e.meterId}: ${e.kWh} kWh`;
    case "tariff-change":
      return `${e.customer}: ${e.from} → ${e.to}`;
  }
}

const events: Event[] = [
  { kind: "outage", ticket: "INC-30418", homes: 120 },
  { kind: "reading", meterId: "E-1001", kWh: 4.2 },
  { kind: "tariff-change", customer: "C-20417", from: "fixed", to: "dynamic" },
];
events.forEach((e) => console.log(describe(e)));
INC-30418: 120 homes without power
E-1001: 4.2 kWh
C-20417: fixed → dynamic

Inside each case, only that member's properties are available, so mixing them up is caught. The tag is real data, so the check also works on objects that came from JSON, where no class or instanceof could help:

type Event =
  | { kind: "outage"; ticket: string; homes: number }
  | { kind: "reading"; meterId: string; kWh: number };

function describe(e: Event): string {
  if (e.kind === "outage") {
    return `${e.ticket}: ${e.kWh}`;
  }
  return e.meterId;
}
example.ts(7,30): error TS2339: Property 'kWh' does not exist on type '{ kind: "outage"; ticket: string; homes: number; }'.

Exhaustiveness checks

What happens when someone adds a fourth status? Every switch that handles statuses should get a new case, and an exhaustiveness check makes the compiler find the ones that did not. The first line of defense is free: when a function has a return type and a case is missing, the end of the function is reachable without a return:

type Status = "open" | "in-progress" | "resolved";

function color(s: Status): string {
  switch (s) {
    case "open":
      return "red";
    case "resolved":
      return "green";
  }
}
example.ts(3,28): error TS2366: Function lacks ending return statement and return type does not include 'undefined'.

The explicit technique uses the type never, the type with no values. After all cases have been handled, narrowing leaves nothing, so the variable's type in the default branch is never. Pass it to a function that only accepts never. As long as every case is handled, that compiles. Add "on-hold" to the union and forget it in the switch, and the leftover type is no longer never:

type Status = "open" | "in-progress" | "resolved" | "on-hold";

function assertNever(value: never): never {
  throw new Error(`Unexpected status: ${JSON.stringify(value)}`);
}

function color(s: Status): string {
  switch (s) {
    case "open":
      return "red";
    case "in-progress":
      return "orange";
    case "resolved":
      return "green";
    default:
      return assertNever(s);
  }
}
example.ts(16,26): error TS2345: Argument of type '"on-hold"' is not assignable to parameter of type 'never'.

The error names exactly the case you forgot. The throw inside assertNever is not wasted either. Types do not exist at run time, so if bad data slips past the compiler, for instance a status from an API that was cast with as, the program fails loudly at the right spot instead of returning undefined:

type Status = "open" | "in-progress" | "resolved";

function assertNever(value: never): never {
  throw new Error(`Unexpected status: ${JSON.stringify(value)}`);
}

function color(s: Status): string {
  switch (s) {
    case "open":
      return "red";
    case "in-progress":
      return "orange";
    case "resolved":
      return "green";
    default:
      return assertNever(s);
  }
}

// Data from outside: the compiler cannot check it
const fromApi = JSON.parse('["open", "resolved", "on-hold"]') as Status[];
for (const s of fromApi) {
  console.log(`${s}: ${color(s)}`);
}
open: red
resolved: green
Error: Unexpected status: "on-hold"

Custom type guards with type predicates

Sometimes the check is more than one expression, or you need it in several places. Put it in a function whose return type is a type predicate, parameter is Type. The function returns a boolean at run time; for the compiler, true means "the argument has this type":

interface Outage {
  kind: "outage";
  ticket: string;
  homes: number;
}
interface Reading {
  kind: "reading";
  meterId: string;
  kWh: number;
}
type Event = Outage | Reading;

function isOutage(e: Event): e is Outage {
  return e.kind === "outage";
}

const events: Event[] = [
  { kind: "outage", ticket: "INC-30418", homes: 120 },
  { kind: "reading", meterId: "E-1001", kWh: 4.2 },
  { kind: "outage", ticket: "INC-30421", homes: 35 },
];

const outages = events.filter(isOutage);
const homes = outages.reduce((sum, o) => sum + o.homes, 0);
console.log(`${outages.length} outages, ${homes} homes`);
2 outages, 155 homes

Passing the guard to filter changes the result type to Outage[], which is why o.homes is allowed. For simple cases you do not even need to write the predicate: since TypeScript 5.5 the compiler infers it from an arrow function such as (r) => r != null. Older articles that tell you to write (r): r is number => … for this are out of date:

const readings = [4.2, null, 3.1, undefined, 5.0];
const valid = readings.filter((r) => r != null);
const total = valid.reduce((sum, r) => sum + r, 0);
console.log(total.toFixed(1));
12.3

Guarding unknown data

The most important use of a type guard is at a trust boundary: data parsed from JSON, received from an API or typed by a user has the type unknown until you have checked it. A guard on unknown checks every property you rely on, and each check narrows a little further:

interface Ticket {
  id: string;
  priority: number;
}

function isTicket(value: unknown): value is Ticket {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    typeof value.id === "string" &&
    "priority" in value &&
    typeof value.priority === "number"
  );
}

const body: unknown = JSON.parse('[{"id":"INC-30412","priority":2},{"id":"INC-30415","priority":"high"}]');

if (Array.isArray(body)) {
  for (const item of body) {
    if (isTicket(item)) {
      console.log(`${item.id} P${item.priority}`);
    } else {
      console.log("Rejected:", JSON.stringify(item));
    }
  }
}
INC-30412 P2
Rejected: {"id":"INC-30415","priority":"high"}

For larger shapes, hand-written guards get long; schema validation libraries generate the check and the type from one definition. Both approaches, and how they fit with fetch, are on Async Code and Typing API Data.

interface Ticket {
  id: string;
  priority: number;
}

function isTicket(value: any): value is Ticket {
  return typeof value.id === "string";
}

const item: unknown = { id: "INC-30415", priority: "high" };
if (isTicket(item)) {
  console.log(item.priority.toFixed(0));
}
TypeError: item.priority.toFixed is not a function

Where narrowing stops

Narrowing is tied to the flow of the code. It lasts until something could have changed the value. Inside a callback that runs later, a let variable may have been reassigned in the meantime, so a narrowing from outside does not always carry over:

function findReading(id: string): number | undefined {
  return id === "E-1001" ? 4.2 : undefined;
}

let kWh = findReading("E-1001");
if (kWh !== undefined) {
  setTimeout(() => console.log(kWh.toFixed(1)), 0);
}
kWh = findReading("E-9999");
example.ts(7,32): error TS18048: 'kWh' is possibly 'undefined'.

The compiler is right: by the time the timer fires, kWh holds the result of the second lookup, which is undefined. Without that last assignment, the same callback compiles, because since TypeScript 5.4 narrowing is kept in closures created after the last assignment. The simplest habit is to use const wherever you can: a const can never change, so its narrowing always holds.

Two other things end a narrowing: assigning a new value to the variable (its type is then the type of what you assigned, within the declared type), and, for properties such as obj.status, the narrowed type is not trusted inside callbacks either. Copying the property into a const first avoids both problems.

Putting it together

You have Narrow with Example
A union of primitives typeof typeof id === "number"
A possibly missing value Equality (or truthiness for strings and objects) kWh !== undefined, x != null
Class instances, errors, dates instanceof err instanceof RangeError
A union of object types you control A discriminant property switch (e.kind)
Object types you do not control in "homes" in e
unknown data from outside A type predicate (or a schema library) isTicket(item)

In all cases the check is real JavaScript that runs, and TypeScript reads it. That is the opposite of a type assertion (as), which changes what the compiler believes without checking anything. Prefer narrowing, and keep assertions for the rare cases where you know more than the compiler can see (see Enums, const Assertions and satisfies).