TypeScript Narrowing — Complete Guide
In this tutorial, you will learn about TypeScript Narrowing. We cover key concepts, practical examples, and best practices to help you master this topic.
TypeScript narrowing is the Process of refining a broad type into a more specific one based on runtime checks — TypeScript's control flow analysis tracks assignments, conditions, loops, and exception paths to narrow types automatically within each code branch.
What You'll Learn
- Control flow analysis basics
- Truthiness and falsiness narrowing
- Equality narrowing and discriminated unions
switchstatement narrowing- Type predicates and assertion functions
Why It Matters
Narrowing is what makes union types practical. Without it, you'd need manual type assertions everywhere. TypeScript's automatic narrowing means you write runtime validation code and get compile-time type safety as a side effect.
Real-World Use
Durga Antivirus Pro's scan result processing uses narrowing extensively — a ScanResult can be ScanningResult | CompletedResult | FailedResult. Control flow on the status property narrows to the correct variant, ensuring that threats is only accessed when the scan completed successfully.
Learning Path
flowchart LR A[Type Guards] --> B[Narrowing] B --> C[Recursive Types] B --> D[You Are Here] C --> E[Variance] E --> F[Overloads]
Control Flow Analysis
TypeScript tracks how the type of a variable changes through code flow:
function example(x: string | number | boolean): void {
// x: string | number | boolean
if (typeof x === "string") {
// x: string
console.log(x.toUpperCase());
} else if (typeof x === "number") {
// x: number
console.log(x.toFixed(2));
} else {
// x: boolean
console.log(x ? "True" : "False");
}
// After the if/else, x: string | number | boolean (back to original union)
}
Think of TypeScript's narrowing as a mind-reader that follows along with your code, updating what it knows about each variable after every check.
Truthiness Narrowing
Truthy/falsy checks narrow types by removing falsy values:
function getFirstElement(arr: number[] | undefined): number | undefined {
if (!arr) {
return undefined;
}
return arr[0]; // arr: number[] (narrowed from undefined)
}
function processValue(value: string | null | undefined): string {
if (value == null) {
return "default";
}
return value.toUpperCase(); // value: string
}
Falsy Values Removed by Truthiness Checks
const falsyValues = [false, 0, "", null, undefined, NaN];
A truthiness check removes all of these from the type.
Equality Narrowing
Using ===, !==, ==, or != narrows types:
function compare(x: string | number, y: string | boolean): void {
if (x === y) {
// Both are string (only common type)
console.log(x.toUpperCase(), y.toUpperCase());
}
}
function getUser(id: string | number): void {
if (typeof id === "string") {
console.log(`String ID: ${id.toUpperCase()}`);
} else {
console.log(`Numeric ID: ${id.toFixed(0)}`);
}
}
in Operator Narrowing
type Admin = { role: "admin"; permissions: string[] };
type User = { role: "user"; department: string };
function handle(person: Admin | User): void {
if ("permissions" in person) {
console.log(person.permissions.join(", ")); // Admin
} else {
console.log(person.department); // User
}
}
switch Statement Narrowing
type Status =
| { status: "idle" }
| { status: "loading"; progress: number }
| { status: "success"; data: string }
| { status: "error"; message: string };
function render(status: Status): string {
switch (status.status) {
case "idle":
return "Ready to start";
case "loading":
return `Loading... ${status.progress}%`;
case "success":
return `Data: ${status.data}`;
case "error":
return `Error: ${status.message}`;
}
}
Discriminated Union Narrowing
The most powerful narrowing pattern — a common property (the discriminant) with literal types:
type RequestState<T> =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "success"; data: T }
| { kind: "error"; error: string };
function handleState<T>(state: RequestState<T>): string {
switch (state.kind) {
case "idle":
return "Not started";
case "loading":
return "Fetching...";
case "success":
return `Got ${JSON.stringify(state.data)}`;
case "error":
return `Failed: ${state.error}`;
}
}
Assertion Functions
Functions that throw if a condition isn't met, narrowing the type afterward:
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new Error("Value must be a string");
}
}
function process(data: unknown): void {
assertIsString(data);
data.toUpperCase(); // Narrowed to string
}
Assignment Narrowing
When you reassign a variable, TypeScript narrows based on the assigned value:
let value: string | number;
value = "hello";
value.toUpperCase(); // OK — value is string
value = 42;
value.toFixed(2); // OK — value is number
// value = true; // Error: boolean is not in the union
Function Return Narrowing
function isString(value: unknown): value is string {
return typeof value === "string";
}
function process(items: (string | number)[]): void {
const strings = items.filter(isString); // TypeScript knows strings is string[]
const numbers = items.filter((x): x is number => typeof x === "number");
}
Common Mistakes
1. Assuming TypeScript Follows Complex Runtime Logic
function badNarrow(value: string | null): string {
// Bad: TypeScript doesn't track external state
if (someExternalFlag) {
// TypeScript still thinks value is string | null
}
// ...
}
TypeScript narrows based on local control flow, not external state.
2. Reassigning Variables Inside Branches
function process(value: string | number): void {
if (typeof value === "string") {
// value: string
value = 42; // Reassign to number — now value: number
}
// value: number (the string path ended with reassignment)
}
3. Using == null vs === null || === undefined
value == null catches both null and undefined. === checks only one.
4. Forgetting Truthiness Doesn't Remove Empty String
function logMessage(msg: string | null): void {
if (msg) {
// msg: string — but "" is falsy!
console.log(msg.toUpperCase()); // Empty string doesn't crash
}
}
5. Not Using asserts Functions for Early Exits
Instead of nested ifs, use assertion functions for cleaner narrowing:
function process(data: unknown): void {
if (typeof data !== "object" || data === null) throw new Error();
if (!("name" in data)) throw new Error();
// data is now narrowed
}
Practice Questions
What is control flow analysis in TypeScript? TypeScript's ability to track type changes through a function's execution path — assignments, conditions, loops, and exception handlers.
How does truthiness narrowing work? A truthy check removes falsy values (false, 0, "", null, undefined, NaN) from the union.
What is a discriminated union? A union type where each member has a common property with a literal type, enabling narrowing via switch/case or if/else on that property.
What does
asserts value is Typedo? It's an assertion function return type that narrows the value after the function returns normally (or throws if the condition fails).
Challenge: Create a discriminated union for a file processing pipeline with states: uploading, processing, completed, failed. Write a function that takes the current state and returns appropriate UI status text.
FAQ
Mini Project: Async Request State Machine
// src/request-state.ts
type RequestState<T> =
| { status: "idle" }
| { status: "pending" }
| { status: "success"; data: T; timestamp: Date }
| { status: "error"; error: string; code?: number };
class RequestManager<T> {
private state: RequestState<T> = { status: "idle" };
getState(): RequestState<T> {
return this.state;
}
async execute(fetchFn: () => Promise<T>): Promise<void> {
this.state = { status: "pending" };
try {
const data = await fetchFn();
this.state = { status: "success", data, timestamp: new Date() };
} catch (err) {
this.state = {
status: "error",
error: err instanceof Error ? err.message : "Unknown error",
};
}
}
render(): string {
const state = this.state;
switch (state.status) {
case "idle":
return "Click to load data";
case "pending":
return "Loading...";
case "success":
return `Loaded ${JSON.stringify(state.data)} at ${state.timestamp.toISOString()}`;
case "error":
return `Error${state.code ? ` [${state.code}]` : ""}: ${state.error}`;
}
}
}
const manager = new RequestManager<string>();
console.log(manager.render()); // Click to load data
manager.execute(async () => {
await new Promise(r => setTimeout(r, 100));
return "Scan results: clean";
}).then(() => {
console.log(manager.render()); // Loaded "Scan results: clean" at ...
});
What's Next
Now explore recursive types for self-referential data:
| Lesson | Description |
|---|---|
| {{< ref "/programming-languages/typescript/25-type-guards" >}} | Review type guards |
| {{< ref "/programming-languages/typescript/27-recursive-types" >}} | Self-referential types, JSON types |
| {{< ref "/programming-languages/typescript/28-variance" >}} | Covariance, contravariance |
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro