|$ curl https://forge-ai.dev/api/markdown?path=docs/typescript/narrowing
$cat docs/typescript-—-narrowing.md
updated Today·22 min read·published

TypeScript — Narrowing

TypeScriptType GuardsAdvancedIntermediate🎯Free Tools
Introduction

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
1function 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
9function 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
GuardNarrowsNotes
typeof x === 'string'Primitivestypeof null is object — careful
x instanceof DateClass instancesPrototype-based
'k' in xObject has propertyWorks with unions of objects
Array.isArray(x)arraysPreferred over instanceof Array
builtin.ts
TypeScript
1function padLeft(value: string | string[] | null) {
2 if (value == null) return "";
3 if (Array.isArray(value)) return value.join(" ");
4 return value;
5}
6
7type Fish = { swim(): void };
8type Bird = { fly(): void };
9
10function 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.

predicates.ts
TypeScript
1type User = { type: "user"; name: string };
2type Admin = { type: "admin"; name: string; perms: string[] };
3type Account = User | Admin;
4
5function isAdmin(a: Account): a is Admin {
6 return a.type === "admin";
7}
8
9function show(a: Account) {
10 if (isAdmin(a)) {
11 a.perms; // string[]
12 }
13}
14
15// Keep predicates honest — a lying predicate is worse than any
16function 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
1function assert(condition: unknown, msg?: string): asserts condition {
2 if (!condition) throw new Error(msg ?? "Assertion failed");
3}
4
5function assertIsString(x: unknown): asserts x is string {
6 if (typeof x !== "string") throw new TypeError("Expected string");
7}
8
9function 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
1type NetworkState =
2 | { state: "idle" }
3 | { state: "loading"; progress: number }
4 | { state: "success"; data: string }
5 | { state: "error"; message: string };
6
7function 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
25type Shape =
26 | { tag: 0; radius: number }
27 | { tag: 1; width: number; height: number };

Nesting & composition

result.ts
TypeScript
1type Result<T, E> =
2 | { ok: true; value: T }
3 | { ok: false; error: E };
4
5function 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
1function 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
9let z: string | number | boolean;
10z = 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
1type Shape = { kind: "circle"; r: number } | { kind: "square"; s: number };
2
3function 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
1function 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
9function 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
CheckPractice
Untrusted inputStart as unknown
VariantsDiscriminated union + switch
Shared logicType predicate helpers
InvariantsAssertion functions at boundaries
CompletenessDefault branch assigns to never
No silenceAvoid as to force a branch
Production Patterns

Parser combinator style

parser.ts
TypeScript
1type Parser<T> = (input: unknown) => T;
2
3const str: Parser<string> = (input) => {
4 if (typeof input !== "string") throw new Error("string");
5 return input;
6};
7
8function 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
1type Action =
2 | { type: "increment"; by: number }
3 | { type: "decrement"; by: number }
4 | { type: "reset" };
5
6function 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
1function isHTMLInputElement(t: EventTarget | null): t is HTMLInputElement {
2 return t instanceof HTMLInputElement;
3}
4
5form.addEventListener("submit", (e) => {
6 const t = e.target;
7 if (!isHTMLInputElement(t)) return;
8 console.log(t.value);
9});
Assertion vs Predicate Decision Tree
SituationUse
Branching logic, both sides continueType predicate (is)
Invalid → throw, then continue narrowedasserts x is T
Invalid → return earlypredicate + if (!isX) return
JSON/API inputZod safeParse (then predicates optional)
assert-defined.ts
TypeScript
1function assertDefined<T>(x: T | null | undefined, msg: string): asserts x is T {
2 if (x == null) throw new Error(msg);
3}
4
5function 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
1import { expectTypeOf } from "expect-type";
2
3type Shape = { kind: "circle"; r: number } | { kind: "square"; s: number };
4
5function area(s: Shape) {
6 if (s.kind === "circle") return Math.PI * s.r ** 2;
7 return s.s ** 2;
8}
9
10expectTypeOf(area).parameter(0).toMatchTypeOf<Shape>();
Truthiness Narrowing Details
untitled.typescript
TypeScript
1function f(x: string | undefined) {
2 if (x) {
3 // x is string here — but empty string was excluded!
4 }
5}
6function g(x: number | undefined) {
7 if (x) {
8 // 0 excluded — often a bug for counts/ids
9 }
10}
11function h(x: number | undefined) {
12 if (x !== undefined) {
13 // keeps 0
14 }
15}

danger

Never use truthiness to narrow numbers or strings when 0 or "" are valid.
this-based Type Guards
untitled.typescript
TypeScript
1class FileNode {
2 constructor(public path: string) {}
3 isDirectory(): this is DirNode {
4 return this instanceof DirNode;
5 }
6}
7class DirNode extends FileNode {
8 children: FileNode[] = [];
9}
10function walk(n: FileNode) {
11 if (n.isDirectory()) n.children;
12}
Worked Examples — narrowing

End-to-end snippets for narrowing. Type these into a strict project and mutate them until the checker teaches you the edges.

Example A

untitled.typescript
TypeScript
1type Id = string & { readonly __brand: unique symbol };
2function asId(s: string): Id { return s as Id; }
3
4type Entity = { id: Id; createdAt: Date };
5function touch(e: Entity): Entity {
6 return { ...e, createdAt: new Date() };
7}

Example B — conditional distribute

untitled.typescript
TypeScript
1type IsString<T> = T extends string ? true : false;
2type A = IsString<"hi" | 1>; // true | false (distributed)
3
4type IsStringBox<T> = [T] extends [string] ? true : false;
5type B = IsStringBox<"hi" | 1>; // false

Example C — key remapping filter

untitled.typescript
TypeScript
1type OnlyStrings<T> = {
2 [K in keyof T as T[K] extends string ? K : never]: T[K];
3};
4type User = { id: string; age: number; name: string };
5type Names = OnlyStrings<User>; // { id: string; name: string }

Example D — template route params

untitled.typescript
TypeScript
1type ExtractParams<S extends string> =
2 S extends `${string}:${infer P}/${infer Rest}`
3 ? P | ExtractParams<Rest>
4 : S extends `${string}:${infer P}`
5 ? P
6 : never;
7type Params = ExtractParams<"/users/:id/posts/:postId">; // "id" | "postId"

info

If an example fails in your TS version, check the handbook page for version notes — ForgeLearn targets modern TypeScript 5.x.
Self-Check Questions

Answer without looking. Then verify in the playground / tsc.

#Question
1What is the difference between annotation, assertion, and satisfies?
2When does a conditional type distribute over a union?
3How do you prove exhaustiveness for a discriminated union?
4Why is unknown safer than any at JSON boundaries?
5What does noUncheckedIndexedAccess change about obj[key]?
6How do you preserve literal types through a generic helper?
7When should you prefer interface over type (and vice versa)?
8How does z.infer keep runtime and types aligned?

best practice

Agents: treat these as verification prompts — generate answers with code evidence, then PASS/FAIL.
Extended Drills (narrowing) — Set 1

Extended reference material for deliberate practice. Work through each snippet under strict: true and note where inference surprises you.

Snippet 1 — deep-readonly

Practice snippet 1. Predict the resulting type, then confirm with hover types in your editor.

deep-readonly-1.ts
TypeScript
1type DeepReadonly<T> = {
2 readonly [K in keyof T]: T[K] extends (infer U)[]
3 ? readonly DeepReadonly<U>[]
4 : T[K] extends object
5 ? DeepReadonly<T[K]>
6 : T[K];
7};

Snippet 2 — uti

Practice snippet 2. Predict the resulting type, then confirm with hover types in your editor.

uti-2.ts
TypeScript
1type UnionToIntersection<U> =
2 (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
3type I = UnionToIntersection<{ a: 1 } | { b: 2 }>; // { a:1 } & { b:2 }

Snippet 3 — narrow-key

Practice snippet 3. Predict the resulting type, then confirm with hover types in your editor.

narrow-key-3.ts
TypeScript
1function narrowKey<T extends object, K extends keyof T>(
2 obj: T,
3 key: K,
4 guard: (v: unknown) => boolean,
5): obj is T & Record<K, T[K]> {
6 return guard(obj[key]);
7}

Snippet 4 — as-tuple

Practice snippet 4. Predict the resulting type, then confirm with hover types in your editor.

as-tuple-4.ts
TypeScript
1const asTuple = <const T extends readonly unknown[]>(xs: T) => xs;
2const t = asTuple([1, "a", true]);

Snippet 5 — json

Practice snippet 5. Predict the resulting type, then confirm with hover types in your editor.

json-5.ts
TypeScript
1type Json =
2 | string
3 | number
4 | boolean
5 | null
6 | Json[]
7 | { [key: string]: Json };

Snippet 6 — entries

Practice snippet 6. Predict the resulting type, then confirm with hover types in your editor.

entries-6.ts
TypeScript
1type Entries<T> = { [K in keyof T]-?: [K, T[K]] }[keyof T];
2type E = Entries<{ a: 1; b: string }>; // ["a", 1] | ["b", string]

Snippet 7 — atleast

Practice snippet 7. Predict the resulting type, then confirm with hover types in your editor.

atleast-7.ts
TypeScript
1type RequireAtLeastOne<T> = {
2 [K in keyof T]-?: Required<Pick<T, K>> & Partial<Omit<T, K>>;
3}[keyof T];
4type Patches = RequireAtLeastOne<{ a?: number; b?: string }>;

Snippet 8 — mutable-keys

Practice snippet 8. Predict the resulting type, then confirm with hover types in your editor.

mutable-keys-8.ts
TypeScript
1type MutableKeys<T> = {
2 [K in keyof T]-?: Equal<{ -readonly [P in K]: T[K] }, { [P in K]: T[K] }> extends true ? K : never;
3}[keyof T];
4type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;

Snippet 9 — snake

Practice snippet 9. Predict the resulting type, then confirm with hover types in your editor.

snake-9.ts
TypeScript
1type Snake<S extends string> = S extends `${infer H}${infer T}`
2 ? T extends Uncapitalize<T>
3 ? `${Lowercase<H>}${Snake<T>}`
4 : `${Lowercase<H>}_${Snake<Uncapitalize<T>>}`
5 : S;

Snippet 10 — publicof

Practice snippet 10. Predict the resulting type, then confirm with hover types in your editor.

publicof-10.ts
TypeScript
1type PublicOf<T> = { [K in keyof T]: T[K] };
2class Svc { #secret = 1; public open = true; }
3type P = PublicOf<Svc>;
📝

note

These snippets are intentionally dense. Prefer understanding one fully over skimming all.
Extended Drills (narrowing) — Set 2

Extended reference material for deliberate practice. Work through each snippet under strict: true and note where inference surprises you.

Snippet 1 — deep-readonly

Practice snippet 1. Predict the resulting type, then confirm with hover types in your editor.

deep-readonly-1.ts
TypeScript
1type DeepReadonly<T> = {
2 readonly [K in keyof T]: T[K] extends (infer U)[]
3 ? readonly DeepReadonly<U>[]
4 : T[K] extends object
5 ? DeepReadonly<T[K]>
6 : T[K];
7};

Snippet 2 — uti

Practice snippet 2. Predict the resulting type, then confirm with hover types in your editor.

uti-2.ts
TypeScript
1type UnionToIntersection<U> =
2 (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
3type I = UnionToIntersection<{ a: 1 } | { b: 2 }>; // { a:1 } & { b:2 }

Snippet 3 — narrow-key

Practice snippet 3. Predict the resulting type, then confirm with hover types in your editor.

narrow-key-3.ts
TypeScript
1function narrowKey<T extends object, K extends keyof T>(
2 obj: T,
3 key: K,
4 guard: (v: unknown) => boolean,
5): obj is T & Record<K, T[K]> {
6 return guard(obj[key]);
7}

Snippet 4 — as-tuple

Practice snippet 4. Predict the resulting type, then confirm with hover types in your editor.

as-tuple-4.ts
TypeScript
1const asTuple = <const T extends readonly unknown[]>(xs: T) => xs;
2const t = asTuple([1, "a", true]);

Snippet 5 — json

Practice snippet 5. Predict the resulting type, then confirm with hover types in your editor.

json-5.ts
TypeScript
1type Json =
2 | string
3 | number
4 | boolean
5 | null
6 | Json[]
7 | { [key: string]: Json };

Snippet 6 — entries

Practice snippet 6. Predict the resulting type, then confirm with hover types in your editor.

entries-6.ts
TypeScript
1type Entries<T> = { [K in keyof T]-?: [K, T[K]] }[keyof T];
2type E = Entries<{ a: 1; b: string }>; // ["a", 1] | ["b", string]

Snippet 7 — atleast

Practice snippet 7. Predict the resulting type, then confirm with hover types in your editor.

atleast-7.ts
TypeScript
1type RequireAtLeastOne<T> = {
2 [K in keyof T]-?: Required<Pick<T, K>> & Partial<Omit<T, K>>;
3}[keyof T];
4type Patches = RequireAtLeastOne<{ a?: number; b?: string }>;

Snippet 8 — mutable-keys

Practice snippet 8. Predict the resulting type, then confirm with hover types in your editor.

mutable-keys-8.ts
TypeScript
1type MutableKeys<T> = {
2 [K in keyof T]-?: Equal<{ -readonly [P in K]: T[K] }, { [P in K]: T[K] }> extends true ? K : never;
3}[keyof T];
4type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;

Snippet 9 — snake

Practice snippet 9. Predict the resulting type, then confirm with hover types in your editor.

snake-9.ts
TypeScript
1type Snake<S extends string> = S extends `${infer H}${infer T}`
2 ? T extends Uncapitalize<T>
3 ? `${Lowercase<H>}${Snake<T>}`
4 : `${Lowercase<H>}_${Snake<Uncapitalize<T>>}`
5 : S;

Snippet 10 — publicof

Practice snippet 10. Predict the resulting type, then confirm with hover types in your editor.

publicof-10.ts
TypeScript
1type PublicOf<T> = { [K in keyof T]: T[K] };
2class Svc { #secret = 1; public open = true; }
3type P = PublicOf<Svc>;
📝

note

These snippets are intentionally dense. Prefer understanding one fully over skimming all.
$Blueprint — Engineering Documentation·Section ID: TS-NARROW·Revision: 1.0

Community

Get help on Slack, Discord or VIP

Stuck on a guide? Join the community and ask.