Introduction
TypeScript has evolved from a simple type checker to a sophisticated type system that rivals those found in functional programming languages. In this guide, we'll explore advanced TypeScript patterns that will help you build robust, maintainable applications.
Conditional Types
Conditional types allow you to create types that depend on other types, similar to ternary operators in JavaScript.
Basic Conditional Types
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
Practical Example: Extract Function Return Type
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
async function fetchUser() {
return { id: 1, name: 'John' };
}
type User = UnwrapPromise<ReturnType<typeof fetchUser>>;
// Result: { id: number; name: string; }
Mapped Types
Mapped types let you transform existing types into new ones by iterating over their properties.
Making Properties Optional or Readonly
type User = {
id: number;
name: string;
email: string;
};
// Make all properties optional
type PartialUser = {
[K in keyof User]?: User[K];
};
// Make all properties readonly
type ReadonlyUser = {
readonly [K in keyof User]: User[K];
};
Advanced: Getters for All Properties
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<User>;
// Result:
// {
// getId: () => number;
// getName: () => string;
// getEmail: () => string;
// }
Template Literal Types
Create new string literal types by combining existing ones.
Building Event Names
type EventType = 'click' | 'focus' | 'blur';
type Element = 'button' | 'input';
type EventHandler = `on${Capitalize<EventType>}${Capitalize<Element>}`;
// Result: 'onClickButton' | 'onClickInput' | 'onFocusButton' | ...
Type-Safe API Routes
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Resource = 'users' | 'posts' | 'comments';
type ApiRoute = `/api/${Resource}` | `/api/${Resource}/${string}`;
const validRoute: ApiRoute = '/api/users/123'; // ✅
const invalidRoute: ApiRoute = '/api/invalid'; // ❌ Type error
Discriminated Unions
Create type-safe state machines and API responses.
State Machine Pattern
type LoadingState = {
status: 'loading';
};
type SuccessState<T> = {
status: 'success';
data: T;
};
type ErrorState = {
status: 'error';
error: Error;
};
type AsyncState<T> = LoadingState | SuccessState<T> | ErrorState;
function handleState<T>(state: AsyncState<T>) {
switch (state.status) {
case 'loading':
return 'Loading...';
case 'success':
return state.data; // TypeScript knows data exists
case 'error':
return state.error.message; // TypeScript knows error exists
}
}
Utility Type Patterns
Deep Readonly
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? DeepReadonly<T[K]>
: T[K];
};
type Config = {
database: {
host: string;
port: number;
};
};
const config: DeepReadonly<Config> = {
database: {
host: 'localhost',
port: 5432,
},
};
// All of these are errors:
config.database = {}; // ❌
config.database.host = 'newhost'; // ❌
Type-Safe Object Keys
function getObjectKeys<T extends object>(obj: T): Array<keyof T> {
return Object.keys(obj) as Array<keyof T>;
}
const user = { id: 1, name: 'John', email: 'john@example.com' };
const keys = getObjectKeys(user); // Array<'id' | 'name' | 'email'>
Real-World Pattern: Type-Safe Event Emitter
type EventMap = {
'user:login': { userId: string; timestamp: Date };
'user:logout': { userId: string };
'post:created': { postId: string; title: string };
};
class TypedEventEmitter<T extends Record<string, any>> {
private listeners: Partial<{
[K in keyof T]: Array<(data: T[K]) => void>;
}> = {};
on<K extends keyof T>(event: K, handler: (data: T[K]) => void) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(handler);
}
emit<K extends keyof T>(event: K, data: T[K]) {
this.listeners[event]?.forEach((handler) => handler(data));
}
}
const emitter = new TypedEventEmitter<EventMap>();
// Type-safe handlers
emitter.on('user:login', (data) => {
console.log(data.userId); // ✅ TypeScript knows the shape
console.log(data.timestamp); // ✅ TypeScript knows the shape
});
// Type-safe emit
emitter.emit('user:login', {
userId: '123',
timestamp: new Date(),
}); // ✅
// This would be a type error:
emitter.emit('user:login', { userId: '123' }); // ❌ Missing timestamp
Best Practices
1. Use `unknown` Instead of `any`
// ❌ Bad
function processData(data: any) {
return data.value; // No type safety
}
// ✅ Good
function processData(data: unknown) {
if (typeof data === 'object' && data !== null && 'value' in data) {
return (data as { value: unknown }).value;
}
throw new Error('Invalid data');
}
2. Prefer Type Inference
// ❌ Redundant
const numbers: number[] = [1, 2, 3];
// ✅ Better - let TypeScript infer
const numbers = [1, 2, 3];
3. Use Const Assertions for Literal Types
// Without const assertion
const config = {
endpoint: '/api/users',
method: 'GET',
};
// Type: { endpoint: string; method: string; }
// With const assertion
const config = {
endpoint: '/api/users',
method: 'GET',
} as const;
// Type: { readonly endpoint: '/api/users'; readonly method: 'GET'; }
Performance Considerations
TypeScript's type checking happens at compile time, so complex types don't impact runtime performance. However, overly complex types can slow down IDE performance and compilation.
Tips for Better Performance:
Conclusion
Advanced TypeScript patterns enable you to catch more bugs at compile time and create more maintainable codebases. Start with the basics like conditional types and mapped types, then gradually incorporate more advanced patterns as your codebase grows.
The key is finding the right balance between type safety and complexity. Not every function needs advanced types, but for critical parts of your application, these patterns can prevent entire categories of bugs.