TypeScript Type Narrowing: Control Flow Analysis
Type narrowing is TypeScript's mechanism for refining broad types into more specific ones based on runtime checks. When you work with union types like `string | number` or nullable values like `User...
Key Insights
- TypeScript’s control flow analysis automatically narrows union types based on runtime checks like
typeof,instanceof, and truthiness, eliminating the need for manual type assertions in most cases - Discriminated unions with literal type properties enable exhaustive pattern matching through switch statements, catching unhandled cases at compile time
- Custom type guard functions using the
ispredicate syntax extend TypeScript’s narrowing capabilities to complex validation logic that the compiler can’t infer automatically
Introduction to Type Narrowing
Type narrowing is TypeScript’s mechanism for refining broad types into more specific ones based on runtime checks. When you work with union types like string | number or nullable values like User | null, TypeScript’s control flow analysis tracks your code’s logic to determine which specific type a value has at any given point.
This matters because it’s the difference between writing type-safe code that feels natural and constantly fighting the compiler with type assertions. Good type narrowing eliminates entire classes of runtime errors while keeping your code readable.
Here’s the fundamental problem and solution:
function processValue(value: string | number) {
// Error: Property 'toUpperCase' doesn't exist on type 'string | number'
// return value.toUpperCase();
if (typeof value === "string") {
// TypeScript knows value is string here
return value.toUpperCase();
}
// TypeScript knows value is number here
return value.toFixed(2);
}
The typeof check doesn’t just affect runtime behavior—TypeScript’s compiler analyzes the control flow and narrows the type automatically within each branch.
Type Guards and Control Flow Basics
TypeScript recognizes several built-in patterns for type narrowing. The compiler analyzes your conditional logic and adjusts types accordingly.
typeof checks work for JavaScript’s primitive types:
function formatValue(value: string | number | boolean) {
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number") {
return value.toFixed(2);
}
// value is boolean here
return value ? "yes" : "no";
}
instanceof checks narrow to class types:
class ApiError extends Error {
constructor(public statusCode: number, message: string) {
super(message);
}
}
function handleError(error: Error | ApiError) {
if (error instanceof ApiError) {
console.log(`API error ${error.statusCode}: ${error.message}`);
return;
}
console.log(`Generic error: ${error.message}`);
}
Truthiness checks narrow nullable types:
function greetUser(name: string | null | undefined) {
if (name) {
// name is string here (null and undefined filtered out)
console.log(`Hello, ${name.toUpperCase()}`);
} else {
// name is null | undefined here
console.log("Hello, guest");
}
}
The key insight: TypeScript tracks which code paths eliminate which types. After a truthiness check, null and undefined are removed from the union in the truthy branch.
Discriminated Unions and Literal Type Narrowing
Discriminated unions are the most powerful pattern for modeling complex domain logic with type safety. They use a common property with literal types to distinguish between variants.
type Success<T> = {
status: "success";
data: T;
};
type Failure = {
status: "error";
error: string;
};
type Result<T> = Success<T> | Failure;
function handleResult(result: Result<User>) {
if (result.status === "success") {
// TypeScript knows result is Success<User>
console.log(result.data.name);
} else {
// TypeScript knows result is Failure
console.log(result.error);
}
}
The status property acts as a discriminant. TypeScript sees the equality check and narrows the entire object type.
Switch statements provide exhaustive checking:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function calculateArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return (shape.base * shape.height) / 2;
default:
// Exhaustiveness check: this will error if we add a new shape
const _exhaustive: never = shape;
throw new Error("Unhandled shape");
}
}
The never type assignment in the default case ensures you handle all variants. If you add a new shape type, TypeScript will error because that new type isn’t assignable to never.
Custom Type Guards (User-Defined Type Predicates)
Sometimes TypeScript can’t infer the narrowing you need. Custom type guards bridge this gap using the is predicate syntax.
interface User {
id: number;
name: string;
email: string;
}
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
typeof (value as User).id === "number" &&
"name" in value &&
typeof (value as User).name === "string" &&
"email" in value &&
typeof (value as User).email === "string"
);
}
function processApiResponse(data: unknown) {
if (isUser(data)) {
// data is User here
console.log(data.email);
} else {
throw new Error("Invalid user data");
}
}
The value is User return type tells TypeScript that if the function returns true, the value is definitely a User. This is more powerful than just returning boolean.
Array filtering is where custom type guards shine:
const mixedArray: (string | number | null)[] = ["hello", 42, null, "world", null];
function isString(value: string | number | null): value is string {
return typeof value === "string";
}
// strings has type string[], not (string | number | null)[]
const strings = mixedArray.filter(isString);
Without the type predicate, filter would return the same union type. With it, TypeScript knows the result array only contains strings.
Advanced Control Flow Patterns
Control flow analysis works through complex boolean logic:
function processInput(value: string | number | null) {
if (typeof value === "string" && value.length > 0) {
// value is string (and we know it's non-empty)
return value.toUpperCase();
}
if (typeof value === "number" || value === null) {
// value is number | null
return value === null ? "N/A" : value.toFixed(2);
}
// value is string here (empty string case)
return "empty";
}
Nested narrowing maintains type information:
type ApiResponse = {
data?: {
user?: {
name: string;
};
};
};
function getUserName(response: ApiResponse): string {
if (response.data) {
if (response.data.user) {
// Fully narrowed through nested checks
return response.data.user.name;
}
}
return "Unknown";
}
Assignment narrowing lets you capture narrowed types:
function example(value: string | number) {
let normalized: string;
if (typeof value === "string") {
normalized = value;
} else {
normalized = value.toString();
}
// normalized is definitely string here
return normalized.toUpperCase();
}
Common Pitfalls and Best Practices
Closures break narrowing because TypeScript can’t guarantee the value hasn’t changed:
function problematic(value: string | null) {
if (value !== null) {
// value is string here
setTimeout(() => {
// Error: value might be null here
// TypeScript can't track mutations across async boundaries
console.log(value.toUpperCase());
}, 100);
}
}
Solution: Capture the narrowed value in a const:
function fixed(value: string | null) {
if (value !== null) {
const validValue = value;
setTimeout(() => {
// validValue is definitely string
console.log(validValue.toUpperCase());
}, 100);
}
}
Mutability issues occur when TypeScript can’t prove a value hasn’t changed:
function checkAndUse(obj: { value: string | number }) {
if (typeof obj.value === "string") {
// obj.value might have changed by the time we use it
// TypeScript doesn't narrow here in all cases
someFunction();
// obj.value could be reassigned by someFunction
}
}
Solution: Extract to a local const:
function checkAndUse(obj: { value: string | number }) {
const value = obj.value;
if (typeof value === "string") {
// value is definitely string and can't be mutated
someFunction();
console.log(value.toUpperCase());
}
}
Array methods sometimes lose narrowing:
const items: (string | null)[] = ["a", null, "b"];
// This doesn't narrow the type
const filtered = items.filter(item => item !== null);
// filtered is still (string | null)[]
// Use a type guard instead
const properlyFiltered = items.filter((item): item is string => item !== null);
// properlyFiltered is string[]
Conclusion
TypeScript’s control flow analysis is sophisticated enough to handle most type narrowing automatically through standard JavaScript patterns. Use typeof and instanceof for simple cases, discriminated unions for complex domain modeling, and custom type guards when you need explicit validation logic.
The key is understanding that TypeScript analyzes your code’s control flow—not just individual expressions. Every if statement, switch case, and boolean expression contributes to the compiler’s understanding of what types are possible at each point.
Avoid type assertions (as casts) unless absolutely necessary. If you find yourself using them frequently, you probably need better type guards or discriminated unions. Let TypeScript’s control flow analysis do the heavy lifting—it’s more reliable than manual assertions and makes refactoring safer.