TypeScript Type Guards — Narrow Types Safely
Advertisement
Type guards are functions that narrow a variable's type. They're essential for working with union types safely. A good type guard eliminates runtime errors and tells the type checker exactly what type a variable is.
typeof Guards
The simplest and most common type guard:
function processValue(value: string | number): void {
// Before guard - type is 'string | number'
if (typeof value === "string") {
// After guard - type is 'string'
console.log(value.toUpperCase());
} else {
// Type is 'number'
console.log(value.toFixed(2));
}
}
// Works with all primitive types
type Primitive = string | number | boolean | bigint | symbol | null | undefined;
function handlePrimitive(value: Primitive) {
if (typeof value === "string") console.log(value.length);
else if (typeof value === "number") console.log(value.toFixed());
else if (typeof value === "boolean") console.log(!value);
else if (typeof value === "symbol") console.log(value.description);
else if (typeof value === "bigint") console.log(value.toString(16));
else console.log(value); // null | undefined
}
instanceof Guards
Check if a value is an instance of a class or constructor:
class User {
constructor(public id: number, public name: string) {}
}
class Admin extends User {
constructor(id: number, name: string, public permissions: string[]) {
super(id, name);
}
}
function describe(entity: User | Admin | string): void {
if (typeof entity === "string") {
console.log(`String: ${entity}`);
} else if (entity instanceof Admin) {
console.log(`Admin ${entity.name} with permissions: ${entity.permissions.join(", ")}`);
} else if (entity instanceof User) {
console.log(`User ${entity.name}`);
}
}
describe(new Admin(1, "Alice", ["read", "write"]));
describe(new User(2, "Bob"));
describe("test");
- typeof Guards
- instanceof Guards
- Custom Type Predicates
- Discriminated Unions
- Advanced Narrowing
- Real-World Examples
- JSON Validation
- Event Handling
- Error Handling
- Performance Considerations
- FAQ
Custom Type Predicates
Create reusable type guard functions with the is keyword:
// Basic predicate
function isString(value: unknown): value is string {
return typeof value === "string";
}
function processArray(items: unknown[]): string[] {
return items.filter(isString);
}
// Predicate for objects
interface User {
id: number;
name: string;
email: string;
}
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value &&
"email" in value &&
typeof (value as any).id === "number" &&
typeof (value as any).name === "string" &&
typeof (value as any).email === "string"
);
}
const data: unknown = JSON.parse('{"id": 1, "name": "Alice", "email": "alice@example.com"}');
if (isUser(data)) {
console.log(`User: ${data.name} (${data.email})`);
}
// Predicate for discriminated unions
type Vehicle = { type: "car"; wheels: 4 } | { type: "motorcycle"; wheels: 2 };
function isCar(vehicle: Vehicle): vehicle is { type: "car"; wheels: 4 } {
return vehicle.type === "car";
}
function getSeats(vehicle: Vehicle): number {
if (isCar(vehicle)) {
return 5; // car property narrowed
}
return 1; // motorcycle
}
Discriminated Unions
The most elegant pattern for complex types:
// Result type - success or error
type Result<T> =
| { status: "success"; data: T }
| { status: "error"; error: string };
function handleResult<T>(result: Result<T>): T | null {
if (result.status === "success") {
// Type is { status: "success"; data: T }
return result.data;
} else {
// Type is { status: "error"; error: string }
console.error(result.error);
return null;
}
}
// API response example
type ApiResponse<T> =
| { ok: true; data: T }
| { ok: false; error: string; code: number };
function processResponse<T>(response: ApiResponse<T>): void {
if (response.ok) {
console.log("Success:", response.data);
} else {
console.error(`Error ${response.code}: ${response.error}`);
}
}
Advanced Narrowing
// Exhaustiveness checking
type Direction = "north" | "south" | "east" | "west";
function move(direction: Direction): void {
switch (direction) {
case "north":
console.log("Moving north");
break;
case "south":
console.log("Moving south");
break;
case "east":
console.log("Moving east");
break;
case "west":
console.log("Moving west");
break;
default:
const exhaustive: never = direction; // Error if case missing
console.log(exhaustive);
}
}
// Assertion functions (TypeScript 3.7+)
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new Error("Value is not a string");
}
}
function processString(value: unknown): void {
assertIsString(value); // After this, type is string
console.log(value.toUpperCase());
}
// Assertion with condition
function assertDefined<T>(value: T | undefined): asserts value is T {
if (value === undefined) {
throw new Error("Value is undefined");
}
}
const maybeUser: User | undefined = fetchUser();
assertDefined(maybeUser);
console.log(maybeUser.name); // No error
Real-World Examples
JSON Validation
interface Product {
id: number;
name: string;
price: number;
}
function isProduct(value: unknown): value is Product {
if (typeof value !== "object" || value === null) return false;
const obj = value as Record<string, unknown>;
return (
typeof obj.id === "number" &&
typeof obj.name === "string" &&
typeof obj.price === "number" &&
obj.price > 0
);
}
function loadProducts(json: string): Product[] {
const data = JSON.parse(json);
if (!Array.isArray(data)) throw new Error("Expected array");
return data.filter(isProduct);
}
Event Handling
type Event =
| { type: "user:created"; userId: number }
| { type: "user:deleted"; userId: number }
| { type: "post:published"; postId: number; authorId: number };
function handleEvent(event: Event): void {
switch (event.type) {
case "user:created":
console.log(`User ${event.userId} created`);
break;
case "user:deleted":
console.log(`User ${event.userId} deleted`);
break;
case "post:published":
console.log(`Post ${event.postId} by user ${event.authorId}`);
break;
}
}
Error Handling
class ValidationError extends Error {
constructor(public fields: string[]) {
super("Validation failed");
}
}
class DatabaseError extends Error {
constructor(public code: string) {
super("Database error");
}
}
type AppError = ValidationError | DatabaseError | Error;
function handleError(error: AppError): void {
if (error instanceof ValidationError) {
console.log(`Validation errors: ${error.fields.join(", ")}`);
} else if (error instanceof DatabaseError) {
console.log(`DB Error (${error.code})`);
} else {
console.log(`Generic error: ${error.message}`);
}
}
Performance Considerations
Type guards have no runtime performance cost — they're just conditional logic. However:
- Avoid repeated checks - store the narrowed type
- Use exhaustive checks - switch statements are fast
- Cache predicates - reuse guard functions
// Good
function process(items: (string | number)[]): void {
const strings = items.filter(isString);
const numbers = items.filter((x) => !isString(x));
// Use strings and numbers
}
// Avoid
function process(items: (string | number)[]): void {
items.forEach((item) => {
if (isString(item)) console.log(item.toUpperCase());
else console.log(item.toFixed());
});
}
FAQ
Q: What's the difference between is and asserts? A: is narrows the type for the caller. asserts throws if the condition fails. Use is for filters, asserts for validations that must succeed.
Q: Can I use type guards with generic types? A: Yes, but you need to preserve generic information in your predicate. This is tricky — often runtime validation libraries like Zod handle this better.
Q: Should I always write type guards for JSON? A: For untrusted data (APIs, user input, files), yes. For internal data, no. Use runtime validation libraries for complex schemas.
Type guards are TypeScript's way of being pragmatic: sometimes you genuinely don't know a type at compile time. Guards let you prove it to the type checker safely. Master them and your code becomes both type-safe and resilient to runtime surprises.
Advertisement