TypeScript Type Guards — Safe Narrowing in Backend APIs
Advertisement
Introduction
Why This Matters
TypeScript's type system is compile-time only. At runtime, all data is untyped JavaScript — HTTP request bodies, database results, external API responses, and parsed JSON can all contain unexpected shapes. Type guards are the bridge between the typed world you design and the untyped world that arrives at runtime.
For backend engineers, this matters constantly. When you JSON.parse a webhook payload or read a row from a database with an untyped driver, you get unknown. Type guards let you narrow that unknown into a concrete, verified type before processing it — preventing runtime crashes and surfacing data quality issues at the earliest possible point.
Mastering type guards also enables exhaustive pattern matching, which means the TypeScript compiler will tell you when you add a new value to a union but forget to handle it in a switch statement. That's proactive bug prevention at scale.
typeof Guards
typeof works for JavaScript primitive types and is the simplest narrowing mechanism.
type ConfigValue = string | number | boolean;
function formatConfigValue(value: ConfigValue): string {
if (typeof value === 'string') {
return `"${value}"`;
} else if (typeof value === 'number') {
return value.toFixed(2);
} else {
return value ? 'true' : 'false';
}
}
// Works with all primitives
function serialize(value: unknown): string {
if (typeof value === 'string') return value;
if (typeof value === 'number') return String(value);
if (typeof value === 'boolean') return String(value);
if (value === null) return 'null';
if (value === undefined) return 'undefined';
return JSON.stringify(value);
}instanceof Guards
instanceof narrows class-based types, including custom error classes.
class ValidationError extends Error {
constructor(
public readonly field: string,
public readonly message: string
) {
super(`Validation error on ${field}: ${message}`);
this.name = 'ValidationError';
}
}
class NotFoundError extends Error {
constructor(public readonly resource: string, public readonly id: string) {
super(`${resource} with id ${id} not found`);
this.name = 'NotFoundError';
}
}
class UnauthorizedError extends Error {
constructor() {
super('Unauthorized');
this.name = 'UnauthorizedError';
}
}
function mapErrorToHttpStatus(error: unknown): { status: number; message: string } {
if (error instanceof ValidationError) {
return { status: 400, message: error.message };
}
if (error instanceof NotFoundError) {
return { status: 404, message: error.message };
}
if (error instanceof UnauthorizedError) {
return { status: 401, message: 'Unauthorized' };
}
if (error instanceof Error) {
return { status: 500, message: 'Internal server error' };
}
return { status: 500, message: 'Unknown error' };
}Custom Type Predicates
Type predicates return value is T — they tell TypeScript the type when the function returns true.
interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
}
interface Order {
id: string;
userId: string;
total: number;
status: 'pending' | 'shipped' | 'delivered';
}
// Type predicate for runtime JSON validation
function isUser(value: unknown): value is User {
if (typeof value !== 'object' || value === null) return false;
const obj = value as Record<string, unknown>;
return (
typeof obj.id === 'string' &&
typeof obj.name === 'string' &&
typeof obj.email === 'string' &&
(obj.role === 'admin' || obj.role === 'user')
);
}
function isOrder(value: unknown): value is Order {
if (typeof value !== 'object' || value === null) return false;
const obj = value as Record<string, unknown>;
return (
typeof obj.id === 'string' &&
typeof obj.userId === 'string' &&
typeof obj.total === 'number' &&
['pending', 'shipped', 'delivered'].includes(obj.status as string)
);
}
// Using predicates with array filter
function parseWebhookPayloads(payloads: unknown[]): User[] {
return payloads.filter(isUser);
}Discriminated Unions
Discriminated unions are the cleanest pattern for modeling state machines and API responses. A shared literal field acts as the discriminant.
type ApiResponse<T> =
| { status: 'success'; data: T }
| { status: 'error'; message: string; code: string }
| { status: 'loading' };
function handleApiResponse<T>(response: ApiResponse<T>): T | null {
switch (response.status) {
case 'success':
return response.data; // TypeScript knows data exists here
case 'error':
console.error(`[${response.code}] ${response.message}`);
return null;
case 'loading':
return null;
}
}
// Event system with discriminated unions
type DomainEvent =
| { type: 'user.created'; userId: string; email: string }
| { type: 'user.deleted'; userId: string }
| { type: 'order.placed'; orderId: string; userId: string; total: number }
| { type: 'payment.failed'; orderId: string; reason: string };
function processEvent(event: DomainEvent): void {
switch (event.type) {
case 'user.created':
console.log(`New user ${event.userId}: ${event.email}`);
break;
case 'user.deleted':
console.log(`User ${event.userId} deleted`);
break;
case 'order.placed':
console.log(`Order ${event.orderId} for $${event.total}`);
break;
case 'payment.failed':
console.log(`Payment failed for order ${event.orderId}: ${event.reason}`);
break;
}
}Assertion Functions
Assertion functions throw if the condition fails — useful for precondition validation at function entry points.
function assert(condition: boolean, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function assertDefined<T>(value: T | null | undefined, name: string): asserts value is T {
if (value === null || value === undefined) {
throw new Error(`Expected ${name} to be defined, got ${String(value)}`);
}
}
function assertString(value: unknown, name: string): asserts value is string {
if (typeof value !== 'string') {
throw new TypeError(`Expected ${name} to be a string`);
}
}
// Usage in service methods
async function processOrder(orderId: string | undefined): Promise<void> {
assertDefined(orderId, 'orderId');
// orderId is string here
const order = await db.orders.findById(orderId);
assertDefined(order, `Order ${orderId}`);
// order is the full object here
assert(order.status === 'pending', 'Order must be in pending state');
// Now safe to process
}Exhaustive Checking with never
Use never to ensure all union members are handled.
type PaymentMethod = 'card' | 'paypal' | 'bank_transfer' | 'crypto';
function getPaymentFee(method: PaymentMethod): number {
switch (method) {
case 'card': return 0.029;
case 'paypal': return 0.034;
case 'bank_transfer': return 0.001;
case 'crypto': return 0.005;
default:
// If you add a new PaymentMethod, TypeScript will error here
const _exhaustive: never = method;
throw new Error(`Unhandled payment method: ${String(_exhaustive)}`);
}
}Common Mistakes
- Using
astype assertion instead of a proper type guard — assertions bypass safety checks - Writing type predicates that return
truewithout actually checking the shape - Forgetting to handle the
nullcase inside object type guards - Not using discriminated unions when modeling mutually exclusive states
- Skipping exhaustive checking — adding enum values without updating switch statements silently
Best Practices
- Prefer discriminated unions over loose
typefields for modeling state - Always check
typeof value !== 'object' || value === nullbefore object property access - Use
assertDefinedin service methods instead of!non-null assertions - Keep type guard functions small and focused on a single type
- Use Zod or Valibot for complex runtime validation rather than hand-rolling deep type guards
Key Takeaways
- Type guards narrow a wide type to a specific type within a conditional branch
typeofworks for primitive types;instanceofworks for classes and custom errors- Custom type predicates return
value is Tand are reusable across the codebase - Discriminated unions use a shared literal field as the discriminant for exhaustive narrowing
asserts conditionfunctions throw on failure and narrow the type for subsequent code- The
nevertype enables exhaustive checking — the compiler errors when a union case is unhandled - Type guards have zero runtime overhead beyond normal conditional logic
- Use runtime validation libraries (Zod, Valibot) for complex external data; hand-write guards for simple shapes
Advertisement