← Back to Blog
Development2024-11-0416 min read

Advanced TypeScript Patterns: From Good to Great

Level up your TypeScript skills with advanced patterns, utility types, and techniques used in production codebases. Write type-safe, maintainable code.

MD
Manoj Dhiman
Advanced TypeScript Patterns: From Good to Great

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

  1. Avoid Complex Unions: Union types with 1000+ members slow down compilation
  2. Use Module Augmentation Sparingly: Can impact compilation performance
  3. Prefer Interfaces Over Types: For object shapes, interfaces perform better
  4. 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! 🎯

#TypeScript#Advanced#Type Safety#Patterns#Best Practices

Related Articles