Advanced TypeScript Patterns: From Good to Great
TypeScript has become the standard for modern JavaScript development. But there's a huge difference between basic TypeScript and advanced, type-safe code that catches bugs before they happen. Let me share the patterns that separate good TypeScript code from great.
Why Advanced TypeScript Matters
Basic TypeScript gives you autocomplete and catches obvious bugs. Advanced TypeScript:
- Makes invalid states unrepresentable
- Catches logic errors at compile time
- Provides self-documenting code
- Enables fearless refactoring
- Improves developer experience
Pattern 1: Discriminated Unions
Model your domain with discriminated unions to make impossible states impossible.
Before:
interface State {
loading: boolean
error: Error | null
data: User | null
}
// Problem: data could be null while loading is false
After:
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; error: Error }
| { status: 'success'; data: User }
// Now TypeScript knows exactly what properties exist
Pattern 2: Branded Types
Create nominal types for type safety:
type UserId = string & { readonly brand: unique symbol }
type ProductId = string & { readonly brand: unique symbol }
function getUser(id: UserId) { }
function getProduct(id: ProductId) { }
const userId = 'user-123' as UserId
const productId = 'product-456' as ProductId
getUser(productId) // Error! 🎉
Pattern 3: Builder Pattern with Fluent API
class QueryBuilder<T> {
private filters: Filter[] = []
where(condition: Filter): QueryBuilder<T> {
this.filters.push(condition)
return this
}
select<K extends keyof T>(...fields: K[]): QueryBuilder<Pick<T, K>> {
return this as any
}
async execute(): Promise<T[]> {
// Execute query
}
}
// Usage with full type safety
const users = await new QueryBuilder<User>()
.where({ age: { gt: 18 } })
.select('name', 'email')
.execute()
// users is User[] with only name and email properties
Pattern 4: Template Literal Types
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
type Route = '/users' | '/products' | '/orders'
type API = `${HTTPMethod} ${Route}`
// Valid: 'GET /users', 'POST /products', etc.
// Invalid: 'PATCH /users', 'GET /invalid'
function callAPI(endpoint: API) { }
callAPI('GET /users') // ✅
callAPI('PATCH /users') // ❌ Error!
Pattern 5: Recursive Types
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| { [key: string]: JSONValue }
const validJSON: JSONValue = {
name: 'John',
age: 30,
hobbies: ['reading', 'coding'],
address: {
city: 'NYC',
coordinates: [40.7128, -74.0060],
},
}
Pattern 6: Conditional Types
type IsString<T> = T extends string ? true : false
type A = IsString<string> // true
type B = IsString<number> // false
// More practical example
type FunctionPropertyNames<T> = {
[K in keyof T]: T[K] extends Function ? K : never
}[keyof T]
interface User {
name: string
age: number
save: () => void
delete: () => void
}
type UserMethods = FunctionPropertyNames<User>
// 'save' | 'delete'
Pattern 7: Mapped Types with Key Remapping
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
type Person = {
name: string
age: number
}
type PersonGetters = Getters<Person>
// {
// getName: () => string
// getAge: () => number
// }
Pattern 8: Utility Type Combinations
// Make specific properties optional
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
interface User {
id: string
name: string
email: string
age: number
}
type UserUpdate = PartialBy<User, 'name' | 'age'>
// id and email required, name and age optional
// Deep readonly
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? DeepReadonly<T[K]>
: T[K]
}
Pattern 9: Type Guards
interface Dog {
type: 'dog'
bark: () => void
}
interface Cat {
type: 'cat'
meow: () => void
}
type Animal = Dog | Cat
function isDog(animal: Animal): animal is Dog {
return animal.type === 'dog'
}
function handleAnimal(animal: Animal) {
if (isDog(animal)) {
animal.bark() // TypeScript knows it's a Dog
} else {
animal.meow() // TypeScript knows it's a Cat
}
}
Pattern 10: Generic Constraints
interface Identifiable {
id: string
}
function findById<T extends Identifiable>(
items: T[],
id: string
): T | undefined {
return items.find(item => item.id === id)
}
// Works with any type that has an id
const user = findById(users, 'user-123')
const product = findById(products, 'product-456')
Real-World Example: Type-Safe API Client
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
interface APIEndpoint {
method: HTTPMethod
path: string
body?: unknown
response: unknown
}
type API = {
'GET /users': {
method: 'GET'
path: '/users'
response: User[]
}
'GET /users/:id': {
method: 'GET'
path: '/users/:id'
response: User
}
'POST /users': {
method: 'POST'
path: '/users'
body: CreateUserDTO
response: User
}
}
type ExtractParams<T extends string> =
T extends `${infer Start}:${infer Param}/${infer Rest}`
? { [K in Param | keyof ExtractParams<Rest>]: string }
: T extends `${infer Start}:${infer Param}`
? { [K in Param]: string }
: {}
async function api<K extends keyof API>(
endpoint: K,
options: {
params?: ExtractParams<API[K]['path']>
body?: API[K] extends { body: infer B } ? B : never
} = {}
): Promise<API[K]['response']> {
// Implementation
}
// Fully type-safe usage
const users = await api('GET /users') // User[]
const user = await api('GET /users/:id', {
params: { id: '123' }
}) // User
const newUser = await api('POST /users', {
body: { name: 'John', email: 'john@example.com' }
}) // User
Best Practices
1. Prefer Type Inference
// Bad
const numbers: number[] = [1, 2, 3]
// Good
const numbers = [1, 2, 3] // TypeScript infers number[]
2. Use const Assertions
const config = {
api: 'https://api.example.com',
timeout: 5000,
} as const
// config.api is 'https://api.example.com', not string
3. Avoid Type Assertions
// Bad
const user = data as User
// Good
function isUser(data: unknown): data is User {
return typeof data === 'object' && data !== null && 'id' in data
}
if (isUser(data)) {
// data is User
}
4. Use Unknown Over Any
// Bad
function process(data: any) {
data.whatever() // No type checking
}
// Good
function process(data: unknown) {
if (typeof data === 'object' && data !== null) {
// Now we can safely work with data
}
}
Performance Considerations
- Avoid Complex Unions: Union types with 1000+ members slow down compilation
- Use Module Augmentation Sparingly: Can impact compilation performance
- Prefer Interfaces Over Types: For object shapes, interfaces perform better
- Use Project References: For large monorepos
Conclusion
Advanced TypeScript patterns transform your code from "has types" to "impossible to misuse." These patterns catch bugs at compile time, provide better IDE support, and make your code self-documenting.
Start incorporating these patterns gradually. Your future self (and teammates) will thank you.
Remember: The goal isn't to use every advanced feature, but to write code that's impossible to use incorrectly.
Happy typing! 🎯