
TypeScript Type Guards: Safely Narrowing Types at Runtime
Dealing with union types where API responses could be success or error? Type guards changed how I handle runtime type checking entirely.

Dealing with union types where API responses could be success or error? Type guards changed how I handle runtime type checking entirely.
Stop using 'any'. How to build reusable, type-safe components using Generics. Advanced patterns with extends, keyof, and infer.

When you break a monolith into microservices, you lose ACID transactions. How do you ensure data consistency across boundaries? We explore the limitations of Two-Phase Commit (2PC) and dive deep into the Saga Pattern, Event Consistency, and practical implementation strategies like Choreography vs Orchestration.

Fixing the crash caused by props coming through as undefined from the parent component.

npm install took 3 minutes, bun install took 10 seconds. It's fast, but can you actually use it in production?

When I first adopted TypeScript, the most frustrating moments came from handling API responses. I naively believed that defining interfaces would make everything safe. But at runtime? Nobody knew what would actually arrive. Success response? Error? Something completely unexpected?
It felt like receiving packages. The label says "laptop," but until you open the box, you don't know if it's actually a laptop, a brick, or just an empty box. TypeScript's type system told me to trust the label, but runtime reality disagreed.
After understanding type guards, I finally bridged this gap. Not by asserting types blindly, but by actually checking and narrowing them systematically.
The biggest revelation came when I understood the difference between as and type guards.
// as: Lying to the compiler
const response = await fetch('/api/user');
const data = await response.json() as User; // Trust me bro
data.name.toUpperCase(); // Runtime error waiting to happen
// Type Guard: Actually checking
function isUser(obj: unknown): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'name' in obj &&
typeof obj.name === 'string'
);
}
const response = await fetch('/api/user');
const data = await response.json();
if (isUser(data)) {
data.name.toUpperCase(); // Safe
} else {
console.error('Invalid user data');
}
Using as is like slapping a "laptop" sticker on a box without checking inside. Type guards actually open the box and verify: "Does it have a screen? Keyboard? Then yes, it's a laptop."
The most basic type guard, leveraging JavaScript's built-in typeof operator.
function processValue(value: string | number) {
if (typeof value === 'string') {
return value.toUpperCase();
}
return value.toFixed(2);
}
// Real-world: Environment variable handling
function getPort(port: string | number | undefined): number {
if (typeof port === 'number') return port;
if (typeof port === 'string') {
const parsed = parseInt(port, 10);
return isNaN(parsed) ? 3000 : parsed;
}
return 3000;
}
For primitives, typeof is the most intuitive choice. TypeScript's compiler understands this pattern perfectly and narrows types accordingly.
Check if an object is an instance of a specific class.
class NetworkError 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) {
if (error instanceof NetworkError) {
console.error(`Network error ${error.statusCode}: ${error.message}`);
// Retry logic
} else if (error instanceof ValidationError) {
console.error(`Invalid ${error.field}: ${error.message}`);
// User feedback
} else {
console.error('Unknown error:', error.message);
}
}
Particularly useful for error handling. Each error type gets its own handling logic.
Distinguish types by checking for property existence.
interface SuccessResponse {
success: true;
data: User[];
}
interface ErrorResponse {
success: false;
error: string;
code: number;
}
type ApiResponse = SuccessResponse | ErrorResponse;
function handleResponse(response: ApiResponse) {
if ('data' in response) {
console.log(`Loaded ${response.data.length} users`);
} else {
console.error(`Error ${response.code}: ${response.error}`);
}
}
This pattern became my go-to for API response handling. Clean separation between success and failure cases.
The most powerful tool. Turn complex conditions into reusable type guards.
interface User {
id: string;
email: string;
role: 'admin' | 'user';
}
function isUser(obj: unknown): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'id' in obj &&
typeof (obj as any).id === 'string' &&
'email' in obj &&
typeof (obj as any).email === 'string' &&
'role' in obj &&
((obj as any).role === 'admin' || (obj as any).role === 'user')
);
}
async function loadUser(id: string) {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
if (!isUser(data)) {
throw new Error('Invalid user data from API');
}
return data; // data is User here
}
Seemed tedious at first, but write once, use everywhere. Essential when dealing with external data.
Add a common discriminant property to union types, then use its value to automatically narrow types.
interface LoadingState {
status: 'loading';
}
interface SuccessState {
status: 'success';
data: User[];
}
interface ErrorState {
status: 'error';
error: string;
}
type State = LoadingState | SuccessState | ErrorState;
function renderUI(state: State) {
switch (state.status) {
case 'loading':
return <Spinner />;
case 'success':
return <UserList users={state.data} />;
case 'error':
return <ErrorMessage message={state.error} />;
}
}
Using this pattern for React state management made my code dramatically cleaner. One status field to rule them all.
A type guard variant. Throw an error if condition fails, narrow type if it succeeds.
function assertIsUser(obj: unknown): asserts obj is User {
if (!isUser(obj)) {
throw new Error('Not a valid user object');
}
}
async function updateUser(id: string, updates: unknown) {
assertIsUser(updates);
// updates is User type from here on
await db.users.update(id, updates);
}
// Another example: null checking
function assertIsDefined<T>(value: T): asserts value is NonNullable<T> {
if (value === null || value === undefined) {
throw new Error('Value must be defined');
}
}
function processUser(user: User | null) {
assertIsDefined(user);
// user is User type now (null excluded)
console.log(user.email);
}
Declarative validation logic. Fail fast, debug easier.
Compile-time verification that all cases are handled.
type PaymentMethod = 'credit_card' | 'paypal' | 'crypto';
function processPayment(method: PaymentMethod) {
switch (method) {
case 'credit_card':
return chargeCreditCard();
case 'paypal':
return chargePaypal();
case 'crypto':
return chargeCrypto();
default:
const _exhaustive: never = method;
throw new Error(`Unhandled payment method: ${_exhaustive}`);
}
}
// Add 'bank_transfer' to PaymentMethod?
// Compile error! Can't assign to never in default case
The compiler alerts you when new types are added. Prevents runtime errors before they happen.
Early code:
// Dangerous
async function fetchUsers() {
const response = await fetch('/api/users');
const data = await response.json() as User[];
return data; // Blind faith
}
After type guards:
// Safe and explicit
interface ApiSuccess<T> {
success: true;
data: T;
}
interface ApiError {
success: false;
error: string;
code: number;
}
type ApiResponse<T> = ApiSuccess<T> | ApiError;
function isApiSuccess<T>(
response: ApiResponse<T>
): response is ApiSuccess<T> {
return response.success === true;
}
async function fetchUsers(): Promise<User[]> {
const response = await fetch('/api/users');
const json = await response.json() as ApiResponse<User[]>;
if (!isApiSuccess(json)) {
throw new Error(`API Error ${json.code}: ${json.error}`);
}
return json.data;
}
Writing type guards manually gets tedious, especially with nested objects and arrays. Schema validation libraries like Zod and Valibot became game changers.
import { z } from 'zod';
// Schema = Type + Runtime validation
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
role: z.enum(['admin', 'user']),
createdAt: z.string().datetime(),
});
type User = z.infer<typeof UserSchema>;
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return UserSchema.parse(data);
}
// Result type without throwing
async function fetchUserSafe(id: string) {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
const result = UserSchema.safeParse(data);
if (!result.success) {
console.error('Validation errors:', result.error.issues);
return null;
}
return result.data;
}
Valibot offers better tree-shaking and smaller bundle size:
import * as v from 'valibot';
const UserSchema = v.object({
id: v.string([v.uuid()]),
email: v.string([v.email()]),
role: v.picklist(['admin', 'user']),
createdAt: v.string([v.isoDateTime()]),
});
type User = v.Output<typeof UserSchema>;
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return v.parse(UserSchema, data);
}
One schema handles both type definition and runtime validation. Use this at API boundaries for guaranteed type safety.
Before learning type guards, I reached for as whenever stuck.
// Bad
const user = response.data as User;
const element = document.querySelector('.button') as HTMLButtonElement;
const config = JSON.parse(text) as Config;
This is telling the compiler "shut up and trust me." Planting runtime bombs.
With type guards:
// Good
if (isUser(response.data)) {
const user = response.data;
}
const element = document.querySelector('.button');
if (element instanceof HTMLButtonElement) {
element.click();
}
const config = JSON.parse(text);
if (isConfig(config)) {
useConfig(config);
}
Looks tedious, but one runtime error changes your mind. Type guards are insurance.
Type guards bridge TypeScript's static type system and JavaScript's dynamic runtime.
Key takeaways:string, number, boolean)is keywordstatus, type)assertsneveras only when truly certain (DOM APIs, etc.)Understanding type guards transformed my perception of TypeScript from a mere annotation tool to a genuine runtime safety enhancer. It's the key that unifies compile time and runtime.