TypeScript Advanced Patterns for Better Type Safety
TypeScript's type system is incredibly powerful, offering features that go far beyond basic type annotations. Let's explore some advanced patterns that will level up your TypeScript skills.
Conditional Types: Types That Make Decisions
Conditional types allow you to create types that change based on conditions, similar to ternary operators in JavaScript:
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
Here's a practical example—creating a type-safe event system:
type EventMap = {
click: { x: number; y: number };
input: { value: string };
submit: { formData: FormData };
};
type EventHandler<K extends keyof EventMap> = (event: EventMap[K]) => void;
function addEventListener<K extends keyof EventMap>(
eventName: K,
handler: EventHandler<K>
) {
// Implementation
}
// Type-safe event handling
addEventListener('click', (event) => {
console.log(event.x, event.y); // ✓ Correctly typed
});
addEventListener('input', (event) => {
console.log(event.value); // ✓ Correctly typed
});
Mapped Types: Transform Existing Types
Mapped types allow you to transform properties of an existing type:
type Optional<T> = {
[K in keyof T]?: T[K];
};
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
interface User {
id: number;
name: string;
email: string;
}
type PartialUser = Optional<User>;
// { id?: number; name?: string; email?: string; }
type ImmutableUser = Readonly<User>;
// { readonly id: number; readonly name: string; readonly email: string; }
Here's a more advanced pattern for creating a deep partial type:
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object
? DeepPartial<T[K]>
: T[K];
};
interface Settings {
theme: {
mode: 'light' | 'dark';
colors: {
primary: string;
secondary: string;
};
};
language: string;
}
const settings: DeepPartial<Settings> = {
theme: {
colors: {
primary: '#000' // Can partially update nested objects
}
}
};
Template Literal Types: String Manipulation at the Type Level
Template literal types let you manipulate string types:
type Route = 'home' | 'about' | 'contact';
type RouteHandler = `handle${Capitalize<Route>}`;
// 'handleHome' | 'handleAbout' | 'handleContact'
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Endpoint = `/api/${string}`;
type APIRoute = `${HTTPMethod} ${Endpoint}`;
const route: APIRoute = 'GET /api/users'; // ✓
const invalid: APIRoute = 'PATCH /api/users'; // ✗ Error
Practical example for route generation:
type ResourceName = 'user' | 'post' | 'comment';
type Action = 'create' | 'read' | 'update' | 'delete';
type Permission = `${ResourceName}:${Action}`;
function checkPermission(permission: Permission) {
// Implementation
}
checkPermission('user:create'); // ✓
checkPermission('user:delete'); // ✓
checkPermission('user:invalid'); // ✗ Type error
Type Guards: Runtime Type Checking
Type guards help TypeScript narrow types at runtime:
interface Cat {
type: 'cat';
meow: () => void;
}
interface Dog {
type: 'dog';
bark: () => void;
}
type Pet = Cat | Dog;
function isCat(pet: Pet): pet is Cat {
return pet.type === 'cat';
}
function handlePet(pet: Pet) {
if (isCat(pet)) {
pet.meow(); // ✓ TypeScript knows it's a Cat
} else {
pet.bark(); // ✓ TypeScript knows it's a Dog
}
}
Utility Types: Extract and Exclude
Extract only the types you need from a union:
type Status = 'idle' | 'loading' | 'success' | 'error';
type LoadingStatus = Extract<Status, 'loading' | 'success'>;
// 'loading' | 'success'
type NonErrorStatus = Exclude<Status, 'error'>;
// 'idle' | 'loading' | 'success'
// Practical example with API responses
type APIResponse<T> =
| { status: 'success'; data: T }
| { status: 'error'; error: Error }
| { status: 'loading' };
type SuccessResponse<T> = Extract<APIResponse<T>, { status: 'success' }>;
// { status: 'success'; data: T }
Branded Types: Extra Type Safety
Sometimes you need to distinguish between two types that are structurally the same:
type Brand<K, T> = K & { __brand: T };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
function getUser(id: UserId) {
// Implementation
}
function getOrder(id: OrderId) {
// Implementation
}
const userId = 'user_123' as UserId;
const orderId = 'order_456' as OrderId;
getUser(userId); // ✓
getUser(orderId); // ✗ Type error - can't mix up IDs!
Const Assertions: Literal Types
Use const assertions to create deeply immutable, literal-typed values:
const config = {
endpoints: {
api: 'https://api.example.com',
auth: 'https://auth.example.com'
},
timeout: 5000
} as const;
// config.endpoints.api is type 'https://api.example.com'
// not string
// This allows for better autocomplete and type safety
type Config = typeof config;
type Endpoint = typeof config.endpoints[keyof typeof config.endpoints];
Conclusion
These advanced TypeScript patterns might seem complex at first, but they provide incredible value in large codebases. They catch bugs at compile-time, improve code completion, and make refactoring safer.
The key is to use these patterns judiciously. Not every piece of code needs advanced types—but when you do need them, TypeScript's type system has you covered.