Type Guards and Narrowing
TypeScript Programming
Type Guards and Narrowing
Type narrowing is the process by which TypeScript refines a broad type to a more specific one within a conditional block. When you check whether a value is a string using typeof, TypeScript understands that inside the if block the value is definitely a string, not a number or object. This mechanism is called control flow analysis, and it is one of the most sophisticated features of the TypeScript compiler. Type guards are the expressions and functions that trigger this narrowing, enabling you to write safe, precise code when working with union types and unknown values.
typeof Guards
The typeof operator works as a type guard for primitive types. TypeScript recognizes typeof checks and automatically narrows the variable's type inside the conditional block.
function padLeft(value: string | number, padding: string | number): string {
// TypeScript narrows 'padding' to 'number' here
if (typeof padding === "number") {
return " ".repeat(padding) + value;
}
// TypeScript narrows 'padding' to 'string' here
return padding + value;
}
console.log(padLeft("Hello", 4)); // " Hello"
console.log(padLeft("Hello", ">> ")); // ">> Hello"
// typeof works for: "string", "number", "boolean",
// "undefined", "object", "function", "bigint", "symbol"
instanceof Guards
The instanceof operator narrows types to specific classes. This is useful when working with class hierarchies or built-in objects like Date, Error, or RegExp.
class ApiError extends Error {
constructor(public statusCode: number, message: string) {
super(message);
}
}
class ValidationError extends Error {
constructor(public field: string, message: string) {
super(message);
}
}
function handleError(error: Error): string {
if (error instanceof ApiError) {
// Narrowed to ApiError — statusCode is accessible
return `API Error ${error.statusCode}: ${error.message}`;
}
if (error instanceof ValidationError) {
// Narrowed to ValidationError — field is accessible
return `Validation Error on "${error.field}": ${error.message}`;
}
return `Unknown Error: ${error.message}`;
}
console.log(handleError(new ApiError(404, "Not Found")));
console.log(handleError(new ValidationError("email", "Invalid format")));
The in Operator
The in operator checks whether a property exists on an object, and TypeScript uses this to narrow discriminated unions and object types.
interface Bird {
fly(): void;
layEggs(): void;
}
interface Fish {
swim(): void;
layEggs(): void;
}
function move(animal: Bird | Fish): void {
if ("fly" in animal) {
// Narrowed to Bird
animal.fly();
} else {
// Narrowed to Fish
animal.swim();
}
}
Custom Type Guard Functions
For complex narrowing logic, you can write custom type guard functions that return a type predicate. The return type value is Type tells TypeScript to narrow the type when the function returns true.
interface Car {
type: "car";
make: string;
model: string;
doors: number;
}
interface Truck {
type: "truck";
make: string;
model: string;
payload: number;
}
type Vehicle = Car | Truck;
// Custom type guard with type predicate
function isCar(vehicle: Vehicle): vehicle is Car {
return vehicle.type === "car";
}
function describeVehicle(vehicle: Vehicle): string {
if (isCar(vehicle)) {
// Narrowed to Car — doors is accessible
return `${vehicle.make} ${vehicle.model} (${vehicle.doors}-door)`;
}
// Narrowed to Truck — payload is accessible
return `${vehicle.make} ${vehicle.model} (${vehicle.payload}kg payload)`;
}
// Assertion function — throws if condition is not met
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new Error(`Expected string, got ${typeof value}`);
}
}
function processInput(input: unknown): string {
assertIsString(input);
// After assertion, input is narrowed to string
return input.toUpperCase();
}
Exhaustive Checking with never
Use the never type to ensure all variants of a union are handled. If you add a new variant and forget to handle it, the compiler reports an error.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "triangle"; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
case "triangle":
return 0.5 * shape.base * shape.height;
default:
// If all cases are handled, shape is 'never' here
const _exhaustive: never = shape;
return _exhaustive;
}
}
Tip: Always handle thedefaultcase in switch statements over discriminated unions with aneverassignment. This gives you a compile-time error whenever you add a new variant without updating the switch.
Key Takeaways
typeofnarrows primitive types;instanceofnarrows class types.- The
inoperator narrows based on property existence in union types. - Custom type guard functions use the
value is Typereturn type predicate. - Assertion functions (
asserts value is Type) narrow types by throwing on failure. - Exhaustive checking with
neverensures all union variants are handled.