TypeScript – Narrowing and Type Guards

August 3, 20268 min readUpdated 9/5/2026

A union is only half a tool. string | number lets you accept both and then stops you using either, and the thing that gets you the rest of the way is narrowing: writing a check the compiler can read, so that inside the block it knows which branch you are on.

TypeScript does this by following your control flow. You are not annotating anything here — you are writing ordinary JavaScript, and the checker is reading it.

typeof

The simplest guard, and it works on the primitives:

function pad(id: string | number) {
  if (typeof id === 'string') {
    return id.padStart(8, '0');   // id is string here
  }
  return id.toFixed(0);           // and number here
}

Note the second line. After the if returns, the only remaining possibility is number, so nothing further is needed — TypeScript narrows on the way out of a branch as well as into one.

The one trap: typeof null === 'object', a JavaScript quirk TypeScript inherits faithfully. Checking typeof x === 'object' does not exclude null.

Truthiness, and where it misleads

A plain if narrows out null and undefined:

function render(imageUrl: string | null) {
  if (imageUrl) {
    return imageUrl.toUpperCase();   // string
  }
  return 'No image';
}

Convenient, and wrong whenever an empty string, 0 or false is a meaningful value:

function label(count: number | null) {
  if (count) return `${count} items`;
  return 'None';        // also returns 'None' for 0 — probably a bug
}

When the distinction matters, compare explicitly:

if (count !== null) { /* 0 gets here too */ }

This is why the app's helpers check if (sub.field) in one place and value !== null in another — the first is a string that is meaningless when empty, the second is not.

Equality

Comparing two values narrows both of them, and comparing against a literal narrows to it:

function fee(orderType: OrderType, subtotal: number) {
  if (orderType === 'DELIVERY' && subtotal > 0) {
    return DELIVERY_FEE;
  }
  return 0;
}

Inside that block orderType is 'DELIVERY' exactly. On the union of five order statuses, a chain of === checks narrows one member at a time.

The in operator

For object unions, in asks whether a property exists:

type Result = { data: Order[] } | { error: string };

function show(result: Result) {
  if ('error' in result) {
    return result.error;      // the error branch
  }
  return result.data.length;  // the data branch
}

Useful when the branches have no shared discriminator field. When they do — and you control the type — the discriminated union below is better.

instanceof

For anything with a constructor, including your own error classes. This is the pizza app's Redux error edge, and it is a clean example of three narrowings in four lines:

export function toApiFailure(err: unknown, fallback: string): ApiFailure {
  if (err instanceof ApiError) {
    return { message: err.message, fieldErrors: err.fieldErrors() };
  }
  return { message: err instanceof Error ? err.message : fallback, fieldErrors: {} };
}

Start with unknown, on which nothing is allowed. The first check proves ApiError, which is what makes err.fieldErrors() — a method on that class and no other — legal. The second proves Error, which permits .message. Neither is available before its check, and that is the point.

The Angular half narrows the same way, over a different set of classes:

  static from(error: unknown): ApiError {
    if (error instanceof ApiError) return error;

    if (error instanceof HttpErrorResponse) {

One caveat worth knowing. instanceof is a prototype check, so subclassing built-ins can break it when compiling to ES5. The app leaves a note where it matters:

    // TypeScript compiling to ES5 breaks `instanceof` for subclassed built-ins. This app targets
    // ES2022 so it is not strictly needed, but it costs one line and removes a nasty trap.
    Object.setPrototypeOf(this, ApiError.prototype);

Discriminated unions

The best-behaved pattern in the language. Give every member of a union a common property with a different literal type, and checking that one property narrows the whole object:

export type Outcome = { ok: true } | { ok: false; failure: ApiFailure };

The discriminant is ok, and because its type is true in one branch and false in the other, a single check settles which shape you have:

const result = await done;

if (result.ok) {
  toast.success('Saved.');
} else {
  showErrors(result.failure);   // only reachable here, and guaranteed present
}

result.failure in the first branch is a compile error, and in the second branch it is not optional. Compare that with a single interface carrying failure?: ApiFailure, where the property is reachable everywhere and guaranteed nowhere.

Any literal type works as a discriminant — the status: 'loading' | 'success' shape from the previous lesson is the same idea with five branches instead of two. A string field named kind, type or status is the usual convention.

Exhaustiveness: making the compiler check for you

Here is what discriminated unions really buy. Narrow through every case and the leftover type is never, which you can assert:

function statusLabel(status: OrderStatus): string {
  switch (status) {
    case 'PENDING_PAYMENT': return 'Awaiting payment';
    case 'PAID':            return 'Paid';
    case 'PREPARING':       return 'In the oven';
    case 'COMPLETED':       return 'Completed';
    case 'CANCELLED':       return 'Cancelled';
    default: {
      const unreachable: never = status;
      return unreachable;
    }
  }
}

Today that compiles, because all five cases are handled and status in the default has type never.

Now add 'REFUNDED' to OrderStatus. The default is reachable with status: 'REFUNDED', that is not assignable to never, and the build fails here — pointing at the function that has not been updated:

Type '"REFUNDED"' is not assignable to type 'never'.

That is the mechanism that turns "add a status" from a search-and-hope exercise into a task with a list. It is worth doing in every switch over a union that matters, and it costs three lines.

Note that noFallthroughCasesInSwitch — on in both pizza configs — is the complementary guard, catching a case that forgets its break.

Type predicates: teaching the compiler your own check

Sometimes the check is a function, and by default a boolean return tells TypeScript nothing:

// This does not compile.
function isProduct(value: unknown): boolean {
  return typeof value === 'object' && value !== null && 'sizes' in value;
}

if (isProduct(thing)) {
  thing.sizes;   // 'thing' is still unknown
  //    ~~~~~
}

Change the return type to a type predicate and the call site narrows:

function isProduct(value: unknown): value is Product {
  return typeof value === 'object' && value !== null && 'sizes' in value;
}

value is Product means "if this returns true, treat the argument as a Product". That is a promise you are making — the compiler checks the body is plausible but cannot verify the logic, so a sloppy predicate is an assertion in disguise. Keep them short and obviously correct.

This is also the fix for the filter problem from lesson 5:

const real = urls.filter((u): u is string => u !== null);   // string[]

TypeScript 5.5 improved this: a simple arrow function whose body is obviously a narrowing check now has its predicate inferred, so the annotation is often unnecessary in newer code. Writing it explicitly still works and is clearer at a glance.

Assertion functions

A variant that throws instead of returning a boolean, narrowing everything after the call:

function assertIsProduct(value: unknown): asserts value is Product {
  if (!isProduct(value)) throw new Error('Not a product');
}

assertIsProduct(thing);
thing.sizes;   // narrowed from here on

Handy at a boundary where a bad value means the operation cannot continue. One quirk: an assertion function must have an explicit type annotation at the point it is declared — it cannot be an arrow function assigned to an inferred const.

The built-in guards

A few standard functions are declared as type predicates, so they narrow without you doing anything. Array.isArray is the one you will reach for:

function count(value: Order | Order[]) {
  if (Array.isArray(value)) {
    return value.length;   // Order[]
  }
  return 1;                // Order
}

Its signature is isArray(arg: any): arg is any[] — the same x is T form you can write yourself.

Optional chaining and nullish coalescing are narrowing tools too, and often the shortest route:

const name = user?.fullName ?? user?.email ?? 'Account';

Each ?. short-circuits to undefined when the left side is absent, so the result type is a union ending in undefined, and ?? then removes it by supplying a value. The Angular app builds its navbar label exactly this way.

Note that ?? differs from || in precisely the way that matters here: it falls back only on null and undefined, not on 0 or the empty string. Reach for it by default, for the same reason the truthiness section warned about if.

Where narrowing is lost

Two situations where the checker gives up, both of which look like bugs until you know the reason.

Reassignment. Narrowing tracks a variable, and any assignment resets it.

Callbacks. This is the common one:

// This does not compile.
function send(order: Order | null) {
  if (!order) return;

  setTimeout(() => {
    console.log(order.id);
    //          ~~~~~
    // 'order' is possibly 'null'.
  }, 1000);
}

TypeScript cannot know when the callback runs, and if order were a let that something else reassigned, the narrowing would no longer hold. With a const parameter this particular case is safe and the compiler is being conservative — but the fix is the same and costs nothing:

function send(order: Order | null) {
  if (!order) return;
  const confirmed = order;                        // captured, and const
  setTimeout(() => console.log(confirmed.id), 1000);
}

The same thing bites on class properties, which genuinely can change between the check and the use. A local copy is the standard answer there too.

Narrowing is not validation

A closing distinction, because it is the one people get wrong. Narrowing convinces the compiler. It does not check anything the compiler was not already able to see.

A type predicate is the clearest case: value is Product is a promise you make, and the body is not verified against it. Write a predicate that checks one field and claims a twelve-field interface, and every call site will believe you.

So at a real boundary — a response, a stored value, a message from elsewhere — narrowing is a way of organising your checks, not a substitute for writing them. If the data genuinely arrives untrusted, the check has to be exhaustive, and a schema library is the honest tool.

Next

Functions — parameters, overloads, and typing the callbacks that most of this lesson's examples were quietly passing around.