LEVEL 5 · LESSON 10 OF 21

Special types and narrowing Foundation

Narrowing

Turn wide union and unknown types into precise ones with typeof, truthiness, equality, in, instanceof, discriminants, type guards and assertion functions, and learn where narrowing lies.

  • 50 min to read and try
  • You need: Special types (any, unknown and never), and Interfaces, unions and literal types
  • You build: A payment router for a banking app that accepts cards, bank transfers and wallets, with every branch checked by the compiler
Test yourself

BY THE END OF THIS LESSON YOU CAN

  • Explain the difference between a declared type and a narrowed type
  • Narrow unions with typeof, equality, in, instanceof and Array.isArray
  • Avoid the truthiness traps with 0, empty strings and NaN
  • Follow how control-flow analysis narrows through returns, assignments and closures
  • Write type guards and assertion functions, and test them

A value that could be two things

Your bank's old mobile app sends transfer amounts as numbers. The new web app sends them as strings, because they come straight from a form field. Your handler has to accept both, so you give the parameter a union type and try to format it:

amount.ts
function formatAmount(amount: number | string): string {
  return "₦" + amount.toFixed(2);
}
What npx tsc --noEmit prints
amount.ts:2:23 - error TS2339: Property 'toFixed' does not exist on type 'string | number'.
  Property 'toFixed' does not exist on type 'string'.

2   return "₦" + amount.toFixed(2);
                        ~~~~~~~


Found 1 error in amount.ts:2

The compiler is right. toFixed is a number method, and amount might be a string. You cannot tell it "it is probably a number". You have to check, in plain JavaScript, and TypeScript follows your check:

amount.ts
function formatAmount(amount: number | string): string {
  if (typeof amount === "string") {
    const parsed = Number(amount);
    return "₦" + parsed.toFixed(2);
  }
  return "₦" + amount.toFixed(2);
}

console.log(formatAmount(1500));
console.log(formatAmount("2500.5"));
Output of npx tsx amount.ts and of the browser terminal
₦1500.00
₦2500.50

Every variable has a declared type: the type written in its annotation, here number | string. At each point in the code, TypeScript also tracks a narrowed type: what the value can still be, given the checks that ran before that point. Inside the if, amount is a string. After the if returns, only number is left. Turning a wide type into a more precise one this way is called narrowing, and the checks that do it are called type guards.

You met narrowing briefly in Interfaces, unions and literal types and used unknown in Special types. This lesson covers every way to narrow, the traps in each, and how to write your own.

typeof

The JavaScript typeof operator returns one of eight strings: "string", "number", "bigint", "boolean", "symbol", "undefined", "object" and "function". TypeScript knows which types go with each string, so comparing typeof x with one of them narrows x:

typeof.ts
function describe(value: string | number | boolean | (() => string)): string {
  if (typeof value === "string") return `text of length ${value.length}`;
  if (typeof value === "number") return `number ${value.toFixed(1)}`;
  if (typeof value === "function") return `function returning ${value()}`;
  return `boolean ${value ? "yes" : "no"}`;
}

console.log(describe("Ada"));
console.log(describe(42));
console.log(describe(() => "PAID"));
console.log(describe(false));
Output of npx tsx typeof.ts and of the browser terminal
text of length 3
number 42.0
function returning PAID
boolean no

After three checks and three returns, only boolean is left on the last line, so TypeScript lets you use value as a boolean without another check.

The typeof null trap

One JavaScript mistake from 1995 is still with us: typeof null is "object". So typeof x === "object" does not rule out null, and TypeScript knows it:

tags.ts
function countTags(tags: string | string[] | null): number {
  if (typeof tags === "object") {
    return tags.length;
  }
  return 1;
}
What npx tsc --noEmit prints
tags.ts:3:12 - error TS18047: 'tags' is possibly 'null'.

3     return tags.length;
             ~~~~


Found 1 error in tags.ts:3

Inside the if, tags is string[] | null, not string[]. Rule out null as well, or use a check that is more precise than typeof:

tags.ts
function countTags(tags: string | string[] | null): number {
  if (tags === null) return 0;
  if (Array.isArray(tags)) return tags.length;
  return 1;
}

console.log(countTags(null), countTags("urgent"), countTags(["urgent", "vip", "lagos"]));
Output of npx tsx tags.ts and of the browser terminal
0 1 3

Array.isArray narrows too. TypeScript's declaration for it is isArray(arg: any): arg is any[], a type guard exactly like the ones you will write yourself later in this lesson.

Truthiness narrowing and its traps

An if (value) check runs its branch only when the value is truthy: anything except the eight falsy values false, 0, -0, 0n, NaN, "", null and undefined. TypeScript uses this to remove null and undefined from a type:

nickname.ts
function greeting(nickname: string | undefined): string {
  if (nickname) {
    return `Welcome back, ${nickname.toUpperCase()}`;
  }
  return "Welcome back";
}

console.log(greeting("Ada"));
console.log(greeting(undefined));
console.log(greeting(""));
Output of npx tsx nickname.ts and of the browser terminal
Welcome back, ADA
Welcome back
Welcome back

That is fine for a nickname, where an empty string really means "no nickname". Now the same habit on money:

balance.ts
function balanceLine(balance: number | null): string {
  if (balance) {
    return `Balance: ₦${balance.toLocaleString("en-NG")}`;
  }
  return "Balance not available";
}

console.log(balanceLine(125000));
console.log(balanceLine(null));
console.log(balanceLine(0));
Output of npx tsx balance.ts and of the browser terminal
Balance: ₦125,000
Balance not available
Balance not available

This compiles, and it is wrong. A customer with exactly ₦0 is told their balance is "not available", because 0 is falsy. TypeScript narrowed correctly: inside the if, balance is a number. But in the else path, balance is still number | null. The compiler never claimed the else branch meant null; the code's author assumed it.

The fix is to say what you mean. When you want "not null or undefined", compare with null:

balance.ts
function balanceLine(balance: number | null): string {
  if (balance !== null) {
    return `Balance: ₦${balance.toLocaleString("en-NG")}`;
  }
  return "Balance not available";
}

console.log(balanceLine(0));

const limit: number | undefined = 0;
console.log(limit || 50000, limit ?? 50000);
Output of npx tsx balance.ts and of the browser terminal
Balance: ₦0
50000 0

The same trap hides in ||. limit || 50000 replaces every falsy value, so a daily limit of 0 (an account that may not transfer anything) silently becomes ₦50,000. ??, the nullish coalescing operator, only replaces null and undefined.

TRUTHINESS ON NUMBERS AND STRINGS

Use if (value) only when every falsy value really means "missing": objects, arrays, and strings where "" is empty. For numbers, amounts, counts, ids and flags, compare with null or undefined explicitly, and default with ??.

Equality narrowing

Comparing with ===, !==, == or != narrows both sides. Comparing with a literal keeps only the matching member; comparing two variables keeps only the types they could share:

equality.ts
type Channel = "sms" | "email" | "push";

function send(channel: Channel, to: string | null, fallback: string | number): string {
  if (to == null) return `${channel}: no recipient`;
  if (channel === "sms") return `sms to ${to.replace(/^0/, "+234")}`;
  if (to === fallback) return `${channel} to ${fallback.toLowerCase()} (same as fallback)`;
  return `${channel} to ${to}`;
}

console.log(send("sms", "08031234567", 0));
console.log(send("email", "ADA@EXAMPLE.COM", "ADA@EXAMPLE.COM"));
console.log(send("push", null, 0));
Output of npx tsx equality.ts and of the browser terminal
sms to +2348031234567
email to ada@example.com (same as fallback)
push: no recipient
  • to == null with two equals signs is the one place where loose equality is useful: it is true for both null and undefined, and TypeScript removes both from the type.
  • After channel === "sms" returns, channel is "email" | "push".
  • to === fallback can only be true when both are strings, because to is a string by then. So inside that branch fallback is narrowed to string too, and .toLowerCase() is allowed.

A switch is a series of === checks, and narrows in exactly the same way in each case.

in and instanceof

The in operator

"iban" in account is true when the object has a property called iban. When the union members differ in which properties they have, in picks the right member:

in.ts
interface LocalAccount {
  accountNumber: string;
  bankCode: string;
}

interface ForeignAccount {
  iban: string;
  swift: string;
}

function payee(account: LocalAccount | ForeignAccount): string {
  if ("iban" in account) {
    return `IBAN ${account.iban} via ${account.swift}`;
  }
  return `NUBAN ${account.accountNumber} at bank ${account.bankCode}`;
}

console.log(payee({ accountNumber: "0123456789", bankCode: "058" }));
console.log(payee({ iban: "GB29NWBK60161331926819", swift: "NWBKGB2L" }));
Output of npx tsx in.ts and of the browser terminal
NUBAN 0123456789 at bank 058
IBAN GB29NWBK60161331926819 via NWBKGB2L

in also works on objects whose type does not mention the property at all. After typeof value === "object" && value !== null && "email" in value, TypeScript knows value has an email property of type unknown. That is how TypeScript and JavaScript together checks request bodies one field at a time.

instanceof

x instanceof C checks whether C.prototype is in the value's prototype chain. It narrows to the class type. This is the everyday way to tell errors apart:

instanceof.ts
class InsufficientFundsError extends Error {
  constructor(readonly shortBy: number) {
    super(`short by ₦${shortBy}`);
  }
}

function explain(error: unknown): string {
  if (error instanceof InsufficientFundsError) return `Top up ₦${error.shortBy} and try again`;
  if (error instanceof Error) return `Transfer failed: ${error.message}`;
  if (error instanceof Date) return `Retry after ${error.toISOString().slice(0, 10)}`;
  return "Transfer failed";
}

console.log(explain(new InsufficientFundsError(2500)));
console.log(explain(new TypeError("amount must be a number")));
console.log(explain(new Date("2026-10-01T00:00:00Z")));
console.log(explain("timeout"));
Output of npx tsx instanceof.ts and of the browser terminal
Top up ₦2500 and try again
Transfer failed: amount must be a number
Retry after 2026-10-01
Transfer failed

Order matters: InsufficientFundsError is also an Error, so the more specific check goes first. Two limits to remember: instanceof only works with classes, never with interfaces or type aliases, because those do not exist at runtime; and an object that came out of JSON.parse is never an instance of your class, even when it has all the right properties.

Discriminated unions

The most reliable way to narrow objects is a discriminant: one property that every member of the union has, with a different literal value in each. A switch on it picks the member, and TypeScript checks every property access in every case:

payment.ts
export type Payment =
  | { kind: "card"; amount: number; last4: string }
  | { kind: "transfer"; amount: number; bankCode: string; reference: string }
  | { kind: "wallet"; amount: number; phone: string };

export function receipt(payment: Payment): string {
  switch (payment.kind) {
    case "card":
      return `₦${payment.amount} charged to card ending ${payment.last4}`;
    case "transfer":
      return `₦${payment.amount} sent to bank ${payment.bankCode}, ref ${payment.reference}`;
    case "wallet":
      return `₦${payment.amount} debited from wallet ${payment.phone}`;
  }
}

console.log(receipt({ kind: "card", amount: 5000, last4: "4242" }));
console.log(receipt({ kind: "wallet", amount: 1200, phone: "08031234567" }));
Output of npx tsx payment.ts and of the browser terminal
₦5000 charged to card ending 4242
₦1200 debited from wallet 08031234567

Notice the function has no final return, and tsc does not complain. Because the switch covers all three kinds, TypeScript knows the end of the function cannot be reached. Add a fourth kind, "ussd", without a case, and you get Function lacks ending return statement. For a clearer error, end with a default that assigns to never, the exhaustiveness check from Basic types.

The discriminant also works after destructuring, as long as the variable you narrow is a const (or a parameter that is never reassigned):

destructure.ts
import type { Payment } from "./payment.js";

function fee(payment: Payment): number {
  const { kind, amount } = payment;
  if (kind === "transfer") {
    return amount > 5000 ? 25 : 10;
  }
  return kind === "card" ? Math.round(amount * 0.015) : 0;
}

console.log(fee({ kind: "transfer", amount: 20000, bankCode: "058", reference: "RENT" }));
console.log(fee({ kind: "card", amount: 5000, last4: "4242" }));
Output of npx tsx destructure.ts and of the browser terminal
25
75

Control-flow analysis

TypeScript does not narrow by looking at one if at a time. It follows every path through the function: returns, throws, breaks, assignments and loops. This is called control-flow analysis. Four rules explain almost everything you will see.

1. Early exits narrow the rest of the block

A return or throw inside an if means the code after it only runs when the check failed. This is why guard clauses work so well in TypeScript:

guard-clauses.ts
interface Account {
  id: string;
  balance: number;
  frozenReason?: string;
}

function withdraw(account: Account | undefined, amount: number): number {
  if (account === undefined) throw new Error("account not found");
  if (account.frozenReason !== undefined) throw new Error(`frozen: ${account.frozenReason}`);
  if (amount > account.balance) throw new Error("insufficient funds");
  return account.balance - amount;
}

console.log(withdraw({ id: "ACC-1", balance: 10000 }, 2500));
for (const account of [undefined, { id: "ACC-2", balance: 100, frozenReason: "KYC pending" }]) {
  try {
    withdraw(account, 50);
  } catch (error) {
    console.log(String(error));
  }
}
Output of npx tsx guard-clauses.ts and of the browser terminal
7500
Error: account not found
Error: frozen: KYC pending

2. Assignment narrows

Assigning a value narrows a variable to the type of what you assigned, within its declared type:

assign.ts
let reference: string | number = "TX-001";
console.log(reference.toLowerCase());

reference = 1042;
console.log(reference.toFixed(0).padStart(6, "0"));
Output of npx tsx assign.ts and of the browser terminal
tx-001
001042

3. A condition stored in a const still narrows

You can give a check a name, and the narrowing survives, as long as both the condition and the value are const (or parameters that are never reassigned):

aliased.ts
function label(value: string | number): string {
  const isText = typeof value === "string";
  return isText ? value.toUpperCase() : value.toFixed(2);
}

console.log(label("pending"), label(99.5));
Output of npx tsx aliased.ts and of the browser terminal
PENDING 99.50

4. Narrowing does not survive everything

Narrowing is a promise about this point in the code. Two situations break it. First, a callback that runs later, on a variable that is assigned again after the callback is created:

closure.ts
function scheduleReminder(email: string | undefined): void {
  if (email === undefined) return;
  setTimeout(() => console.log(`remind ${email.toLowerCase()}`), 1000);
  email = undefined;
}
What npx tsc --noEmit prints
closure.ts:3:42 - error TS18048: 'email' is possibly 'undefined'.

3   setTimeout(() => console.log(`remind ${email.toLowerCase()}`), 1000);
                                           ~~~~~


Found 1 error in closure.ts:3

When the timer fires, email really is undefined, and the compiler caught it. Remove the last line, or copy the value into a const before the callback, and it compiles. The second situation goes the other way: the compiler keeps a narrowing that is no longer true. It assumes a function call does not change the properties it has already checked:

stale.ts
interface Order {
  total: number;
  discount?: number;
}

function removeExpiredDiscount(order: Order): void {
  delete order.discount;
}

const order: Order = { total: 20000, discount: 2000 };
if (order.discount !== undefined) {
  removeExpiredDiscount(order);
  try {
    console.log(order.discount.toFixed(2));
  } catch (error) {
    console.log(String(error));
  }
}
Output of npx tsx stale.ts and of the browser terminal
TypeError: Cannot read properties of undefined (reading 'toFixed')

This compiles and crashes. Checking every property again after every call would make almost all real code fail to compile, so TypeScript makes a practical choice and trusts you. The defence is a habit: read the property into a const (const discount = order.discount), check that, and use it. A const cannot change behind your back.

Custom type guards: value is T

Sometimes the check you need is longer than one operator, or you need it in many places. Put it in a function whose return type is a type predicate, parameterName is Type. When the function returns true, TypeScript narrows the argument to Type; when it returns false, it removes Type from the argument's type:

The examples in this section share the Payment union from before, kept in its own file:

types.ts
export type Payment =
  | { kind: "card"; amount: number; last4: string }
  | { kind: "transfer"; amount: number; bankCode: string; reference: string }
  | { kind: "wallet"; amount: number; phone: string };
guards.ts
import type { Payment } from "./types.js";

type CardPayment = Extract<Payment, { kind: "card" }>;

function isCardPayment(payment: Payment): payment is CardPayment {
  return payment.kind === "card";
}

const payments: Payment[] = [
  { kind: "card", amount: 5000, last4: "4242" },
  { kind: "wallet", amount: 1200, phone: "08031234567" },
  { kind: "card", amount: 800, last4: "1881" },
];

const cards = payments.filter(isCardPayment);
console.log(cards.map((card) => card.last4));

const first = payments[1];
if (first !== undefined && !isCardPayment(first)) {
  console.log(first.kind);
}
Output of npx tsx guards.ts and of the browser terminal
[ '4242', '1881' ]
wallet

Extract<Payment, { kind: "card" }> picks the card member out of the union; you will meet it properly in Advanced and utility types. Passing the guard to filter is the classic use: filter has an overload that takes a type predicate, so cards is CardPayment[], not Payment[], and card.last4 is allowed. In the else direction, !isCardPayment(first) leaves "transfer" | "wallet" payments.

Inferred type predicates

Since TypeScript 5.5, a short arrow function that only returns a check is inferred as a type guard, so you often do not need to write the predicate:

inferred.ts
const amounts = [1500, undefined, 700, undefined, 2500];
const present = amounts.filter((amount) => amount !== undefined);
const total = present.reduce((sum, amount) => sum + amount, 0);
console.log(present.length, total);
Output of npx tsx inferred.ts and of the browser terminal
3 4700

Before 5.5, present was (number | undefined)[] and sum + amount was an error. Inference only works when the function has no written return type and its body is a check TypeScript can read both ways; write the predicate yourself when the check is longer.

A guard can lie

TypeScript does not check that the body of a guard matches its predicate. It believes the true, and it also believes the false. A guard that returns false for some values that are the type makes the else branch lie:

lying-guard.ts
function isBigAmount(amount: number | string): amount is number {
  return typeof amount === "number" && amount >= 1_000_000;
}

function audit(amount: number | string): string {
  if (isBigAmount(amount)) return `review ₦${amount}`;
  try {
    return `auto-approve ${amount.toUpperCase()}`;
  } catch (error) {
    return String(error);
  }
}

console.log(audit(2_500_000));
console.log(audit("450000"));
console.log(audit(450000));
Output of npx tsx lying-guard.ts and of the browser terminal
review ₦2500000
auto-approve 450000
TypeError: amount.toUpperCase is not a function

In the else path TypeScript removed number, so it let amount.toUpperCase() through. But ₦450,000 as a number also ends up there. A predicate must mean "is this type", exactly. Checks about the value ("is big", "is active") belong in a plain boolean function after narrowing.

Assertion functions: asserts value is T

A type guard returns a boolean and leaves the if to you. An assertion function throws when the check fails, and when it returns normally, the narrowing holds for the rest of the scope. There are two forms: asserts condition narrows by whatever expression you pass, and asserts value is T narrows one argument:

assert.ts
function assert(condition: unknown, message: string): asserts condition {
  if (!condition) throw new Error(message);
}

function assertIsString(value: unknown, field: string): asserts value is string {
  if (typeof value !== "string") throw new TypeError(`${field} must be a string`);
}

function openAccount(name: unknown, openingDeposit: number | null): string {
  assertIsString(name, "name");
  assert(openingDeposit !== null, "opening deposit is required");
  assert(openingDeposit >= 1000, "opening deposit must be at least ₦1000");
  return `${name.trim()}: ₦${openingDeposit.toFixed(2)}`;
}

console.log(openAccount("  Ada Obi ", 5000));
for (const [name, deposit] of [[42, 5000], ["Grace", null], ["Grace", 500]] as const) {
  try {
    openAccount(name, deposit);
  } catch (error) {
    console.log(String(error));
  }
}
Output of npx tsx assert.ts and of the browser terminal
Ada Obi: ₦5000.00
TypeError: name must be a string
Error: opening deposit is required
Error: opening deposit must be at least ₦1000

After assert(openingDeposit !== null, …), openingDeposit is a number on every following line, exactly as if you had written if (openingDeposit === null) throw …. Node.js ships the same idea as assert from node:assert, which is typed asserts value too.

One rule surprises everyone the first time. An assertion function must be called through a name whose type is written out. An arrow function stored in a const without an annotation does not count:

arrow-assert.ts
const assertPositive = (amount: number): asserts amount => {
  if (amount <= 0) throw new RangeError("amount must be positive");
};

assertPositive(500);
What npx tsc --noEmit prints
arrow-assert.ts:5:1 - error TS2775: Assertions require every name in the call target to be declared with an explicit type annotation.

5 assertPositive(500);
  ~~~~~~~~~~~~~~

  arrow-assert.ts:1:7 - 'assertPositive' needs an explicit type annotation.
    1 const assertPositive = (amount: number): asserts amount => {
            ~~~~~~~~~~~~~~


Found 1 error in arrow-assert.ts:5

Control-flow analysis runs before full type inference, so it needs to know from the declaration alone that a call can narrow. Use a function declaration, as above, or annotate the const: const assertPositive: (amount: number) => asserts amount = ….

Guards and assertion functions are also how you check data from outside your program, where the value starts as unknown. That boundary work, and why a guard must check every field, is covered in TypeScript and JavaScript together.

Build: a payment router

REASON IT OUT

Before you write the router

A payments endpoint receives already-parsed payments, some from the old app (amount as a number) and some from the web app (amount as a string). Each must be routed to the card processor, the bank transfer service or the wallet service. Before reading the code, think:

  • Which of the checks in this lesson fits each decision: which processor, which amount format, which error?
  • What amounts must be refused? Think about "abc", "0", -500, NaN, and a string with spaces.
  • Where would if (amount) be a bug?
  • A processor can fail with a known error class, a generic Error, or something that is not an error at all. How do you report each?
Show the reasoning

The processor is chosen by the kind discriminant, with a switch so a new kind cannot be forgotten. The amount format is a typeof check. The amount must be converted and then checked with Number.isFinite and > 0: Number("abc") is NaN, which is still of type number, so no type check catches it. if (amount) would treat 0 as "missing" instead of "invalid", and would accept "0", a non-empty string. Errors are unknown in catch, so they are narrowed with instanceof, most specific class first, with a fallback for anything else.

router.ts
type Amount = number | string;

type Payment =
  | { kind: "card"; amount: Amount; last4: string }
  | { kind: "transfer"; amount: Amount; bankCode: string }
  | { kind: "wallet"; amount: Amount; phone: string };

class ProcessorDownError extends Error {
  constructor(readonly processor: string) {
    super(`${processor} is unavailable`);
  }
}

function toKobo(amount: Amount): number {
  const naira = typeof amount === "string" ? Number(amount.trim()) : amount;
  if (!Number.isFinite(naira) || naira <= 0) {
    throw new RangeError(`invalid amount: ${JSON.stringify(amount)}`);
  }
  return Math.round(naira * 100);
}

function charge(payment: Payment, kobo: number): string {
  switch (payment.kind) {
    case "card":
      return `card *${payment.last4} charged ${kobo} kobo`;
    case "transfer":
      if (payment.bankCode === "999") throw new ProcessorDownError("NIP gateway");
      return `transfer to bank ${payment.bankCode}: ${kobo} kobo`;
    case "wallet":
      if (!/^0\d{10}$/.test(payment.phone)) throw "wallet rejected the phone number";
      return `wallet ${payment.phone} debited ${kobo} kobo`;
    default: {
      const unhandled: never = payment;
      throw new Error(`unknown payment ${JSON.stringify(unhandled)}`);
    }
  }
}

export function route(payment: Payment): string {
  try {
    return "OK    " + charge(payment, toKobo(payment.amount));
  } catch (error) {
    if (error instanceof ProcessorDownError) return `RETRY ${error.processor}`;
    if (error instanceof RangeError) return `FAIL  ${error.message}`;
    if (error instanceof Error) return `FAIL  unexpected: ${error.message}`;
    return `FAIL  ${String(error)}`;
  }
}

const requests: Payment[] = [
  { kind: "card", amount: 5000, last4: "4242" },
  { kind: "transfer", amount: " 12500.50 ", bankCode: "058" },
  { kind: "wallet", amount: "1200", phone: "08031234567" },
  { kind: "card", amount: "abc", last4: "1881" },
  { kind: "card", amount: 0, last4: "1881" },
  { kind: "transfer", amount: 700, bankCode: "999" },
  { kind: "wallet", amount: 300, phone: "12345" },
];

for (const payment of requests) console.log(route(payment));
Output of npx tsx router.ts and of the browser terminal
OK    card *4242 charged 500000 kobo
OK    transfer to bank 058: 1250050 kobo
OK    wallet 08031234567 debited 120000 kobo
FAIL  invalid amount: "abc"
FAIL  invalid amount: 0
RETRY NIP gateway
FAIL  wallet rejected the phone number

Every narrowing form from this lesson is here. typeof picks the amount format, Number.isFinite catches the NaN that no type can, the discriminant chooses the processor, never guards against a forgotten kind, and instanceof sorts the errors. Converting to kobo (hundredths of a naira) as a whole number keeps money out of floating-point trouble from here on.

Testing narrowing code

The compiler proves that each branch uses the value correctly. It cannot prove that your checks send each value to the right branch. That is what tests are for, and guards are the easiest code in the world to test: a table of values and the answer you expect for each.

guard.test.ts
function isNuban(value: unknown): value is string {
  return typeof value === "string" && /^\d{10}$/.test(value);
}

const cases: [unknown, boolean][] = [
  ["0123456789", true],
  ["012345678", false],
  ["01234567890", false],
  ["01234 56789", false],
  [1234567890, false],
  [null, false],
  [undefined, false],
  [["0123456789"], false],
];

let failures = 0;
for (const [input, expected] of cases) {
  const actual = isNuban(input);
  if (actual !== expected) failures++;
  console.log(`${actual === expected ? "PASS" : "FAIL"} ${JSON.stringify(input)} -> ${actual}`);
}
console.log(`${failures} failures`);
Output of npx tsx guard.test.ts and of the browser terminal
PASS "0123456789" -> true
PASS "012345678" -> false
PASS "01234567890" -> false
PASS "01234 56789" -> false
PASS 1234567890 -> false
PASS null -> false
PASS undefined -> false
PASS ["0123456789"] -> false
0 failures

For a guard, always include the values that are almost right (nine digits, eleven digits, a number instead of a string) and the values typeof gets wrong (null, arrays). In a real project the same table goes into a Vitest it.each, which you will meet in the testing lesson.

Narrowing in production code

  • Prefer discriminants to property sniffing. "iban" in account works, but a kind property is explicit, survives refactoring, and gives you exhaustiveness checks.
  • Narrow once, near the edge. A function that receives string | number should turn it into one type early (like toKobo) so the rest of the code has nothing to narrow.
  • Copy before callbacks. const email = user.email, then use email in the callback. It removes both closure problems and stale property narrowing.
  • Keep predicates exact. A predicate means "is this type", nothing else. Keep guards short and test them with a table.
  • Never narrow with as. payment as CardPayment compiles without any check. The next lesson explains exactly what as does, and when it is acceptable.

Practice

TRY IT YOURSELF

Fix the zero-quantity bug

This inventory function says "out of stock" for beans, which is right (0 left), and also for garri, which the shop does not sell at all. Rewrite it so a missing item returns "unknown item", 0 returns "out of stock", and anything else returns "N in stock".

stock.ts
const stock: Record<string, number | undefined> = { rice: 12, beans: 0 };

function stockLine(item: string): string {
  const quantity = stock[item];
  if (!quantity) return "out of stock";
  return `${quantity} in stock`;
}

console.log(stockLine("rice"), "|", stockLine("beans"), "|", stockLine("garri"));
Output of npx tsx stock.ts and of the browser terminal
12 in stock | out of stock | out of stock
Show a solution
stock.ts
const stock: Record<string, number | undefined> = { rice: 12, beans: 0 };

function stockLine(item: string): string {
  const quantity = stock[item];
  if (quantity === undefined) return "unknown item";
  if (quantity === 0) return "out of stock";
  return `${quantity} in stock`;
}

console.log(stockLine("rice"), "|", stockLine("beans"), "|", stockLine("garri"));
Output of npx tsx stock.ts and of the browser terminal
12 in stock | out of stock | unknown item

!quantity lumped together two different facts: "not in the list" (undefined) and "none left" (0). Compare with each value explicitly and each case gets its own branch.

TRY IT YOURSELF

A guard for a union member

Bookings are { status: "confirmed"; seat: string } or { status: "waitlisted"; position: number }. Write a type guard isConfirmed and use it with filter to print the seats of the confirmed bookings.

Show a solution
bookings.ts
type Confirmed = { status: "confirmed"; seat: string };
type Waitlisted = { status: "waitlisted"; position: number };
type Booking = Confirmed | Waitlisted;

function isConfirmed(booking: Booking): booking is Confirmed {
  return booking.status === "confirmed";
}

const bookings: Booking[] = [
  { status: "confirmed", seat: "12A" },
  { status: "waitlisted", position: 3 },
  { status: "confirmed", seat: "4C" },
];

console.log(bookings.filter(isConfirmed).map((booking) => booking.seat));
Output of npx tsx bookings.ts and of the browser terminal
[ '12A', '4C' ]

The predicate is exact: it returns true for every confirmed booking and only for them, so both the true and the false narrowing are honest. With TypeScript 5.5 or later, bookings.filter((b) => b.status === "confirmed") infers the same predicate.

TRY IT YOURSELF

An assertion for account ids

Write assertAccountId(value: unknown): asserts value is string that accepts strings like "ACC-00042" (ACC- and five digits) and throws a TypeError otherwise. Call it on three values and print either the id in lower case or the error.

Show a solution
account-id.ts
function assertAccountId(value: unknown): asserts value is string {
  if (typeof value !== "string" || !/^ACC-\d{5}$/.test(value)) {
    throw new TypeError(`not an account id: ${JSON.stringify(value)}`);
  }
}

for (const input of ["ACC-00042", "ACC-42", 42]) {
  try {
    assertAccountId(input);
    console.log(input.toLowerCase());
  } catch (error) {
    console.log(String(error));
  }
}
Output of npx tsx account-id.ts and of the browser terminal
acc-00042
TypeError: not an account id: "ACC-42"
TypeError: not an account id: 42

After the call, input is a string, so toLowerCase compiles. JSON.stringify in the message shows the difference between the string "42" and the number 42.

Recap

  • A variable has a declared type and, at each point in the code, a narrowed type. Checks written in plain JavaScript narrow it.
  • typeof narrows primitives and functions, but typeof null is "object". Array.isArray, in and instanceof narrow objects; instanceof needs a class.
  • Truthiness removes null and undefined, but also sends 0, "" and NaN to the other branch. Compare explicitly for numbers and use ?? for defaults.
  • Equality narrows both sides; == null covers null and undefined. A switch on a discriminant is the most reliable way to narrow objects.
  • Control-flow analysis follows returns, throws and assignments. Narrowing can be lost in callbacks, and can go stale on properties after a function call.
  • value is T guards and asserts value is T functions package checks for reuse. The compiler trusts them in both directions, so keep them exact and test them.

Next: Type assertions, the one tool that changes a type without any check at all, and when it is still the right choice.

Test yourself

Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.