Narrowing is how TypeScript refines a wide type into a smaller one based on runtime checks. Mastery means writing code where every branch is proven safe — without assertions.
This page goes deeper than the type-guards intro: assertion functions, discriminant evolution, truthiness pitfalls, and exhaustiveness patterns.
Control Flow Analysis
cfa.ts
TypeScript
1
function print(id: string | number) {
2
if (typeof id === "string") {
3
console.log(id.toUpperCase()); // string
4
} else {
5
console.log(id.toFixed(2)); // number
6
}
7
}
8
9
function example(x: string | null | undefined) {
10
if (x == null) return; // catches null and undefined
11
x; // string
12
}
⚠
warning
Truthy checks treat "" / 0 / false as absent — often a bug. Prefer == null or explicit === checks.
Built-in Guards
Guard
Narrows
Notes
typeof x === 'string'
Primitives
typeof null is object — careful
x instanceof Date
Class instances
Prototype-based
'k' in x
Object has property
Works with unions of objects
Array.isArray(x)
arrays
Preferred over instanceof Array
builtin.ts
TypeScript
1
function padLeft(value: string | string[] | null) {
2
if (value == null) return "";
3
if (Array.isArray(value)) return value.join(" ");
4
return value;
5
}
6
7
type Fish = { swim(): void };
8
type Bird = { fly(): void };
9
10
function move(animal: Fish | Bird) {
11
if ("swim" in animal) animal.swim();
12
else animal.fly();
13
}
Custom Type Predicates
A predicate arg is Type tells TS that a true return means arg has that type.
// Keep predicates honest — a lying predicate is worse than any
16
function isString(x: unknown): x is string {
17
return typeof x === "string";
18
}
✕
danger
Type predicates are not verified against the function body beyond return type assignability to boolean. A wrong predicate lies to the checker.
Assertion Functions
Assertion functions (asserts x is T / asserts x) narrow for the rest of the scope after the call — or throw.
asserts.ts
TypeScript
1
function assert(condition: unknown, msg?: string): asserts condition {
2
if (!condition) throw new Error(msg ?? "Assertion failed");
3
}
4
5
function assertIsString(x: unknown): asserts x is string {
6
if (typeof x !== "string") throw new TypeError("Expected string");
7
}
8
9
function demo(x: unknown) {
10
assertIsString(x);
11
x.toUpperCase(); // string
12
13
assert(x.length > 0);
14
// x still string; condition asserted true
15
}
📝
note
Assertion functions must be concrete functions (not arrows assigned to variables) for control-flow to apply in older TS targets — prefer function declarations.
Discriminated Unions Deep Dive
Share a literal discriminant field. Switch on it. Exhaust with never.
du.ts
TypeScript
1
type NetworkState =
2
| { state: "idle" }
3
| { state: "loading"; progress: number }
4
| { state: "success"; data: string }
5
| { state: "error"; message: string };
6
7
function label(s: NetworkState): string {
8
switch (s.state) {
9
case "idle":
10
return "Idle";
11
case "loading":
12
return `Loading ${s.progress}%`;
13
case "success":
14
return s.data;
15
case "error":
16
return s.message;
17
default: {
18
const _x: never = s;
19
return _x;
20
}
21
}
22
}
23
24
// Discriminant can be boolean or number literals too
25
type Shape =
26
| { tag: 0; radius: number }
27
| { tag: 1; width: number; height: number };
Nesting & composition
result.ts
TypeScript
1
type Result<T, E> =
2
| { ok: true; value: T }
3
| { ok: false; error: E };
4
5
function map<T, U, E>(r: Result<T, E>, f: (t: T) => U): Result<U, E> {
6
if (r.ok) return { ok: true, value: f(r.value) };
7
return r;
8
}
Equality & Assignment Narrowing
eq.ts
TypeScript
1
function example(x: string | number, y: string | boolean) {
2
if (x === y) {
3
// x and y both narrowed to string
4
x.toUpperCase();
5
y.toLowerCase();
6
}
7
}
8
9
let z: string | number | boolean;
10
z = Math.random() > 0.5 ? "hi" : 42;
11
// after assignment, z is string | number (boolean removed for CFA in some flows)
Aliasing & Pitfalls
Narrowing may not follow through property aliases the way you expect. Prefer switching on the discriminant directly.
alias.ts
TypeScript
1
type Shape = { kind: "circle"; r: number } | { kind: "square"; s: number };
2
3
function area(shape: Shape) {
4
const { kind } = shape;
5
if (kind === "circle") {
6
return Math.PI * shape.r ** 2; // OK in modern TS with discriminant destructuring
7
}
8
return shape.s ** 2;
9
}
10
11
// Mutating discriminant after narrow — don't; keep unions immutable in practice
unknown In, never Out
unknown-never.ts
TypeScript
1
function parse(input: unknown) {
2
if (typeof input !== "object" || input === null) throw new Error("object");
3
if (!("id" in input) || typeof (input as { id: unknown }).id !== "string") {
4
throw new Error("id");
5
}
6
return input as { id: string }; // better: Zod
7
}
8
9
function assertNever(x: never): never {
10
throw new Error(`Unexpected: ${JSON.stringify(x)}`);
11
}
✓
best practice
Prefer Zod (or similar) for unknown JSON. Hand-rolled narrowing is error-prone at scale.
Narrowing Checklist
Check
Practice
Untrusted input
Start as unknown
Variants
Discriminated union + switch
Shared logic
Type predicate helpers
Invariants
Assertion functions at boundaries
Completeness
Default branch assigns to never
No silence
Avoid as to force a branch
Production Patterns
Parser combinator style
parser.ts
TypeScript
1
type Parser<T> = (input: unknown) => T;
2
3
const str: Parser<string> = (input) => {
4
if (typeof input !== "string") throw new Error("string");
5
return input;
6
};
7
8
function obj<T extends Record<string, Parser<unknown>>>(shape: T): Parser<{
9
[K in keyof T]: T[K] extends Parser<infer U> ? U : never;
10
}> {
11
return (input) => {
12
if (typeof input !== "object" || input === null) throw new Error("object");
13
const out: any = {};
14
for (const k of Object.keys(shape) as (keyof T)[]) {
15
out[k] = shape[k]((input as any)[k]);
16
}
17
return out;
18
};
19
}
📝
note
Prefer Zod in apps; hand-rolled parsers teach narrowing mechanics.
Redux-style actions
redux.ts
TypeScript
1
type Action =
2
| { type: "increment"; by: number }
3
| { type: "decrement"; by: number }
4
| { type: "reset" };
5
6
function reducer(state: number, action: Action): number {
7
switch (action.type) {
8
case "increment":
9
return state + action.by;
10
case "decrement":
11
return state - action.by;
12
case "reset":
13
return 0;
14
default: {
15
const _e: never = action;
16
return _e;
17
}
18
}
19
}
DOM type guards
dom.ts
TypeScript
1
function isHTMLInputElement(t: EventTarget | null): t is HTMLInputElement {
2
return t instanceof HTMLInputElement;
3
}
4
5
form.addEventListener("submit", (e) => {
6
const t = e.target;
7
if (!isHTMLInputElement(t)) return;
8
console.log(t.value);
9
});
Assertion vs Predicate Decision Tree
Situation
Use
Branching logic, both sides continue
Type predicate (is)
Invalid → throw, then continue narrowed
asserts x is T
Invalid → return early
predicate + if (!isX) return
JSON/API input
Zod safeParse (then predicates optional)
assert-defined.ts
TypeScript
1
function assertDefined<T>(x: T | null | undefined, msg: string): asserts x is T {
2
if (x == null) throw new Error(msg);
3
}
4
5
function firstDefined<T>(items: (T | null | undefined)[]): T {
6
const x = items.find((i) => i != null);
7
assertDefined(x, "empty");
8
return x;
9
}
Testing Narrowing
Write tests that would fail to compile if discriminants change — e.g. exhaustiveness helpers in type tests.
type-tests.ts
TypeScript
1
import { expectTypeOf } from "expect-type";
2
3
type Shape = { kind: "circle"; r: number } | { kind: "square"; s: number };
4
5
function area(s: Shape) {
6
if (s.kind === "circle") return Math.PI * s.r ** 2;