EduFlow

Section 1 · Types as a Design Tool

Narrowing and Control-Flow Analysis

20 min4 min readCode playground

Lesson at a glance

Use typeof, equality, in, instanceof and early returns to narrow wide types, and recognise the places where TypeScript forgets a narrowing so you can keep it.

  1. 1Control-flow analysis
  2. 2The narrowing toolbox
  3. 3Where narrowing gets lost
  4. 4Narrowing arrays with filter

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

A Cartwheel order has shippingFeeCents: number | null. null means the fee is not known yet (the address is incomplete). 0 means free shipping. Here is the label shown at checkout, as it was first written:

TypeScript
function shippingLabel(feeCents: number | null): string {
  if (!feeCents) return 'Calculated at checkout';
  if (feeCents === 0) return 'Free';
  return `${(feeCents / 100).toFixed(2)}`;
}

Every free-shipping customer saw “Calculated at checkout”. TypeScript did not complain, because after the first line feeCents is number, and comparing a number with 0 is perfectly legal. The types were right; the narrowing was wrong. This lesson is about how TypeScript narrows, so you can make it work for you instead of trusting it blindly.

Control-flow analysis

TypeScript tracks the type of each variable through every branch and return. The same name can have a different type on each line:

TypeScript
function shippingLabel(feeCents: number | null): string {
  // feeCents: number | null
  if (feeCents === null) return 'Calculated at checkout';
  // feeCents: number
  if (feeCents === 0) return 'Free';
  // feeCents: number
  return `${(feeCents / 100).toFixed(2)}`;
}

This is control-flow analysis. Each check splits the flow into branches, and on each branch the variable’s type is the set of values that could still be there. When a branch returns, the code after it only sees what is left.

Control flow of shippingLabel. The parameter starts as number or null. The check feeCents equals null branches to a return with type null. The remaining path has type number, then the check feeCents equals 0 branches to a return for free shipping, and the final path is a positive fee of type number. number | null feeCents === null ? yes null → "Calculated…" no number: === 0 ? yes 0 → "Free" number → "$4.99"
Each check splits the flow; the type after it is what can still be there

The narrowing toolbox

You narrow with ordinary JavaScript checks; TypeScript understands each one:

Check Narrows Example
typeof x === 'string' primitives typeof amount === 'number'
x === null, x !== undefined literal values coupon !== null
'prop' in x object unions by property 'trackingNumber' in shipment
x instanceof C class instances err instanceof TypeError
Array.isArray(x) arrays Array.isArray(input)

Truthiness (if (x)) also narrows, and it is the one to be careful with. It removes null and undefined, but also 0, '', NaN and false. For an object or an array, a truthiness check is fine. For a number or a string where zero or empty means something, compare against null explicitly.

in and instanceof in practice

Two checks deserve a closer look because the checkout client uses them constantly. The payment provider answers with one of two shapes, and only one has a receiptId:

TypeScript
type PaymentReply = { receiptId: string; capturedCents: number } | { declineCode: string };

function summarize(reply: PaymentReply): string {
  if ('receiptId' in reply) return `Paid, receipt ${reply.receiptId}`;
  return `Declined (${reply.declineCode})`;
}

'receiptId' in reply keeps only the union members that declare receiptId. It works, but it narrows on the absence of a field in the other members, so adding receiptId? to the decline shape later would quietly break it. When you control the types, a literal discriminant (next lesson) is sturdier.

instanceof is the tool for errors. Under strict, the variable in a catch clause is unknown, because JavaScript lets you throw anything. You narrow before you touch it:

TypeScript
function describeFailure(err: unknown): string {
  if (err instanceof Error) return err.message;
  if (typeof err === 'string') return err;
  return 'Unknown error';
}

Where narrowing gets lost

Narrowing is a fact about one point in the flow. A callback runs at some other time, so TypeScript is careful about what it carries inside:

TypeScript
type Order = { id: number; couponCode: string | null };

function couponBanner(order: Order, names: string[]): string[] {
  if (order.couponCode === null) return [];
  return names.map((n) => `${n}, ${order.couponCode.toUpperCase()} is applied`);
  // Error: 'order.couponCode' is possibly 'null'.
}

Outside the arrow function, order.couponCode is string. Inside it, the narrowing is gone, because couponCode is a mutable property and something could set it back to null before map calls the callback. The fix is to capture the narrowed value in a const, which can never change:

TypeScript
function couponBanner(order: Order, names: string[]): string[] {
  const code = order.couponCode;
  if (code === null) return [];
  return names.map((n) => `${n}, ${code.toUpperCase()} is applied`);
}

Since TypeScript 5.4, parameters and let variables also keep their narrowing inside callbacks, as long as they are not assigned again after the callback is created. Properties never do. Copying into a const works in every version and makes the intent obvious.

Narrowing arrays with filter

filter used to be a classic frustration: coupons.filter((c) => c !== null) still had type (string | null)[]. Since TypeScript 5.5, TypeScript infers a type predicate from simple arrow functions like this one, so the result is string[]:

TypeScript
const coupons: (string | null)[] = ['WELCOME10', null, 'LOYAL5'];
const codes = coupons.filter((c) => c !== null); // string[]

The inference only happens when the check proves the type in both directions. (c) => !!c is not inferred as a predicate: a falsy result could mean '', not null, so TypeScript cannot conclude that a rejected value is null. The same goes for filter(Boolean). Write the explicit comparison and you get both the narrowing and the correct handling of empty strings.

The exercise below has all three bugs from this lesson in one file. In the next lesson you will design types that make narrowing almost automatic: discriminated unions.

Key takeaways

  • Control-flow analysis gives a variable a different type at each point in the code, based on the checks and returns that came before.
  • Truthiness checks also remove 0 and '', so check against null or undefined explicitly when those values are meaningful.
  • Narrowing of a mutable property is dropped inside callbacks; copy the value into a const before the callback to keep it.
  • Since TypeScript 5.5, a simple arrow like (c) => c !== null passed to filter is inferred as a type predicate, so the result is narrowed.

Further reading

Check your understanding

4 questions

Question 1 of 4

shippingFeeCents is null when the fee is not known yet and 0 for free shipping. What does this return for a free-shipping order?

TypeScript
function label(fee: number | null) {
  if (!fee) return 'Calculated at checkout';
  return fee === 0 ? 'Free' : `${fee / 100}`;
}
Question 2 of 4

Why is order.coupon an error inside the callback?

TypeScript
if (order.coupon !== null) {
  names.map((n) => `${n}: ${order.coupon.toUpperCase()}`);
}
Question 3 of 4

Under TypeScript 5.9, what is the type of codes?

Question 4 of 4

Which check narrows input: Order | Order[] to Order[]?

Practice

Code playground

Fix three narrowing bugs

This file has the three narrowing problems from the lesson. Fix them without using the non-null assertion !, as or any:

  1. shippingLabel must return 'Free' when shippingFeeCents is 0, 'Calculated at checkout' when it is null, and a dollar amount like '$4.99' otherwise.
  2. usedCoupons must return a real string[] of the non-null coupon codes.
  3. couponBanner must compile and still upper-case the code inside the callback.
type Order = {
  id: number;
  couponCode: string | null;
  // null = not known yet (address incomplete); 0 = free shipping
  shippingFeeCents: number | null;
};

function shippingLabel(order: Order): string {
  if (!order.shippingFeeCents) return 'Calculated at checkout';
  if (order.shippingFeeCents === 0) return 'Free';
  return `${(order.shippingFeeCents / 100).toFixed(2)}`;
}

function usedCoupons(orders: Order[]): string[] {
  return orders.map((o) => o.couponCode).filter((c) => c);
}

function couponBanner(order: Order, names: string[]): string[] {
  if (order.couponCode === null) return [];
  return names.map((n) => `${n}, ${order.couponCode.toUpperCase()} is applied`);
}

const orders: Order[] = [
  { id: 1001, couponCode: 'welcome10', shippingFeeCents: 0 },
  { id: 1002, couponCode: null, shippingFeeCents: null },
  { id: 1003, couponCode: 'loyal5', shippingFeeCents: 499 },
];
console.log(orders.map(shippingLabel), usedCoupons(orders));
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Console

(no output)

Checks

  • The file type-checks with zero errors
  • shippingLabel returns ‘Free’ for 0, ‘Calculated at checkout’ for null and ‘$4.99’ for 499
  • usedCoupons keeps only the non-null codes, in order
  • couponBanner upper-cases the code and returns [] when there is no coupon