Advanced TypeScript Patterns for Large Codebases
TypeScript has become the de facto standard for large-scale JavaScript applications. But many developers only scratch the surface of what's possible. Let me share advanced patterns that have saved me countless hours of debugging.
Beyond Basic Types
Discriminated Unions
One of the most powerful TypeScript features:
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
function handleResult<T>(result: Result<T>) {
if (result.success) {
// TypeScript knows result.data exists here
return result.data;
} else {
// TypeScript knows result.error exists here
console.error(result.error);
}
}
Template Literal Types
Build type-safe APIs:
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Endpoint = '/users' | '/posts' | '/comments';
type APIRoute = \`\${HTTPMethod} \${Endpoint}\`;
// Valid: 'GET /users', 'POST /posts', etc.
const route: APIRoute = 'GET /users';
Advanced Utility Types
Deep Partial
Make all properties optional, recursively:
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object
? DeepPartial<T[P]>
: T[P];
};
interface Config {
database: {
host: string;
port: number;
};
}
const partialConfig: DeepPartial<Config> = {
database: { host: 'localhost' } // port is optional
};
Type-Safe Event Emitters
type EventMap = {
'user:login': { userId: string; timestamp: number };
'user:logout': { userId: string };
'error': { message: string; code: number };
};
class TypedEventEmitter<T extends Record<string, any>> {
on<K extends keyof T>(event: K, handler: (data: T[K]) => void) {
// Implementation
}
emit<K extends keyof T>(event: K, data: T[K]) {
// Implementation
}
}
const emitter = new TypedEventEmitter<EventMap>();
emitter.on('user:login', (data) => {
// data is typed as { userId: string; timestamp: number }
});
Branded Types
Prevent mixing similar types:
type UserId = string & { __brand: 'UserId' };
type PostId = string & { __brand: 'PostId' };
function getUser(id: UserId) { /* ... */ }
function getPost(id: PostId) { /* ... */ }
const userId = 'user-123' as UserId;
const postId = 'post-456' as PostId;
getUser(userId); // ✓
getUser(postId); // ✗ Type error!
Builder Pattern with Type Safety
class QueryBuilder<T, Selected extends keyof T = never> {
private query: any = {};
select<K extends keyof T>(...fields: K[]) {
return this as any as QueryBuilder<T, Selected | K>;
}
where(condition: Partial<T>) {
this.query.where = condition;
return this;
}
execute(): Promise<Pick<T, Selected>[]> {
return Promise.resolve([]);
}
}
interface User {
id: string;
name: string;
email: string;
age: number;
}
const users = await new QueryBuilder<User>()
.select('id', 'name')
.where({ age: 25 })
.execute();
// users is typed as Array<{ id: string; name: string }>
Conditional Types Magic
Extract Function Return Types
type ExtractPromiseType<T> = T extends Promise<infer U> ? U : T;
async function fetchUser() {
return { id: '1', name: 'John' };
}
type User = ExtractPromiseType<ReturnType<typeof fetchUser>>;
// { id: string; name: string }
Type-Safe Object Paths
type PathOf<T> = {
[K in keyof T]: K extends string
? T[K] extends object
? K | \`\${K}.\${PathOf<T[K]>}\`
: K
: never;
}[keyof T];
interface User {
profile: {
name: string;
address: {
city: string;
};
};
}
type UserPath = PathOf<User>;
// 'profile' | 'profile.name' | 'profile.address' | 'profile.address.city'
Strict Configuration
My `tsconfig.json` for large projects:
{
"compilerOptions": {
"strict": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true
}
}
Performance Tips
Avoid Excessive Type Instantiation
// Bad - creates many type instantiations
type Bad<T> = T extends any[] ? T[number] : T;
// Good - use conditional types sparingly
type Good<T> = T extends Array<infer U> ? U : T;
Use Type Assertions Wisely
// Avoid
const user = response.data as User;
// Better - validate first
function isUser(data: unknown): data is User {
return typeof data === 'object' &&
data !== null &&
'id' in data;
}
if (isUser(response.data)) {
const user = response.data;
}
Real-World Patterns
API Client with Type Safety
interface APIEndpoints {
'/users': {
GET: { response: User[] };
POST: { body: CreateUserDTO; response: User };
};
'/users/:id': {
GET: { params: { id: string }; response: User };
PUT: { params: { id: string }; body: UpdateUserDTO; response: User };
};
}
class TypedAPIClient<T extends Record<string, any>> {
get<Path extends keyof T>(
path: Path,
...args: T[Path] extends { GET: { params: infer P } }
? [params: P]
: []
): Promise<T[Path]['GET']['response']> {
// Implementation
}
post<Path extends keyof T>(
path: Path,
body: T[Path]['POST']['body']
): Promise<T[Path]['POST']['response']> {
// Implementation
}
}
const client = new TypedAPIClient<APIEndpoints>();
// Fully typed!
const users = await client.get('/users');
const user = await client.get('/users/:id', { id: '123' });
Conclusion
Advanced TypeScript isn't about complexity—it's about encoding your domain logic into the type system so the compiler catches bugs before they reach production.
Start simple, add advanced patterns where they provide clear value, and always prioritize code readability. The goal is developer productivity, not TypeScript gymnastics.
Want to read more articles?