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.
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:
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:
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.
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:
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:
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:
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:
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[]:
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
0and'', so check againstnullorundefinedexplicitly when those values are meaningful. - Narrowing of a mutable property is dropped inside callbacks; copy the value into a
constbefore the callback to keep it. - Since TypeScript 5.5, a simple arrow like
(c) => c !== nullpassed tofilteris inferred as a type predicate, so the result is narrowed.