Back to Blog
TypeScript

Advanced TypeScript Patterns for Better Code

Exploring sophisticated TypeScript patterns that can make your code more type-safe, maintainable, and expressive.

Tom
15 November 2024
14 min read

TypeScript has evolved tremendously since its early days, offering developers increasingly sophisticated ways to express type relationships and build more robust applications. Today, we'll explore some advanced patterns that can elevate your TypeScript code from good to exceptional.

Conditional Types: Beyond the Basics

Conditional types are one of TypeScript's most powerful features, allowing us to create types that adapt based on other types. While many developers are familiar with basic conditional types, there are sophisticated patterns that can solve complex type problems elegantly.

// Advanced conditional type for deep readonly
type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object 
    ? DeepReadonly<T[P]> 
    : T[P]
}

// Usage
interface User {
  name: string;
  address: {
    street: string;
    city: string;
  }
}

type ReadonlyUser = DeepReadonly<User>
// Result: All properties, including nested ones, become readonly

This pattern demonstrates how conditional types can recursively transform complex data structures, ensuring type safety at every level of nesting.

Template Literal Types in Practice

Template literal types allow us to create types that manipulate string literals at the type level. This is particularly powerful for creating type-safe APIs and ensuring consistency across string-based interfaces.

// Creating a type-safe event system
type EventName = 'user' | 'product' | 'order'
type EventAction = 'created' | 'updated' | 'deleted'

type EventType = `${EventName}:${EventAction}`
// Result: 'user:created' | 'user:updated' | 'user:deleted' | ...

// Type-safe event handler
function handleEvent(eventType: EventType, data: any) {
  // TypeScript knows exactly which event types are valid
  console.log(`Handling ${eventType}`, data)
}

handleEvent('user:created', userData) // ✅ Valid
handleEvent('user:invalid', userData) // ❌ Type error

Mapped Types for Data Transformation

Mapped types allow us to create new types by transforming properties of existing types. When combined with utility types and conditional logic, they become incredibly powerful for data transformation scenarios.

// Create optional version of specific properties
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>

interface CreateUserRequest {
  email: string;
  name: string;
  age: number;
  isActive: boolean;
}

// Make age and isActive optional for updates
type UpdateUserRequest = PartialBy<CreateUserRequest, 'age' | 'isActive'>

// Result: { email: string; name: string; age?: number; isActive?: boolean }

Advanced Generic Constraints

Generic constraints become particularly powerful when working with complex data structures. By constraining generics with sophisticated type relationships, we can create highly flexible yet type-safe APIs.

// Type-safe object path accessor
type PathKeys<T> = {
  [K in keyof T]: T[K] extends object 
    ? K | `${K & string}.${PathKeys<T[K]> & string}`
    : K
}[keyof T]

function getNestedValue<T, P extends PathKeys<T>>(
  obj: T, 
  path: P
): any {
  return path.split('.').reduce((current, key) => current[key], obj)
}

const user = {
  name: 'Tom',
  address: {
    street: '123 Main St',
    city: 'London'
  }
}

// TypeScript provides autocomplete and validation
const street = getNestedValue(user, 'address.street') // ✅
const invalid = getNestedValue(user, 'address.postcode') // ❌ Type error

The Builder Pattern with TypeScript

The builder pattern becomes particularly elegant in TypeScript when we use conditional types to track which properties have been set, ensuring compile-time validation of required fields.

interface DatabaseConfig {
  host: string;
  port: number;
  username: string;
  password: string;
}

type ConfigBuilder<T = {}> = {
  host: (host: string) => ConfigBuilder<T & { host: string }>;
  port: (port: number) => ConfigBuilder<T & { port: number }>;
  username: (username: string) => ConfigBuilder<T & { username: string }>;
  password: (password: string) => ConfigBuilder<T & { password: string }>;
  build: T extends DatabaseConfig ? () => DatabaseConfig : never;
}

// Only allows build() when all required properties are set
const config = new DatabaseConfigBuilder()
  .host('localhost')
  .port(5432)
  .username('admin')
  .password('secret')
  .build() // ✅ All properties set

const incomplete = new DatabaseConfigBuilder()
  .host('localhost')
  .build() // ❌ Type error - missing required properties

Type-Level Programming

TypeScript's type system is Turing complete, meaning we can perform computations at the type level. This opens up possibilities for sophisticated type manipulations that were previously impossible.

// Type-level arithmetic for array operations
type Length<T extends readonly any[]> = T['length']
type Head<T extends readonly any[]> = T extends readonly [infer H, ...any[]] ? H : never
type Tail<T extends readonly any[]> = T extends readonly [any, ...infer T] ? T : []

type Reverse<T extends readonly any[]> = T extends readonly [...infer Rest, infer Last]
  ? [Last, ...Reverse<Rest>]
  : []

type Example = Reverse<[1, 2, 3, 4]> // Result: [4, 3, 2, 1]

// Type-safe array operations
function reverseArray<T extends readonly any[]>(arr: T): Reverse<T> {
  return arr.slice().reverse() as Reverse<T>
}

Branded Types for Enhanced Safety

Branded types (also known as nominal types) allow us to create distinct types from the same underlying type, preventing accidental mixing of conceptually different values.

// Create branded types for different ID types
type Brand<T, B> = T & { __brand: B }

type UserId = Brand<string, 'UserId'>
type ProductId = Brand<string, 'ProductId'>
type OrderId = Brand<string, 'OrderId'>

// Factory functions for creating branded types
function createUserId(id: string): UserId {
  return id as UserId
}

function createProductId(id: string): ProductId {
  return id as ProductId
}

// Type-safe functions
function getUser(id: UserId) { /* ... */ }
function getProduct(id: ProductId) { /* ... */ }

const userId = createUserId('user_123')
const productId = createProductId('product_456')

getUser(userId) // ✅ Correct
getUser(productId) // ❌ Type error - cannot pass ProductId to function expecting UserId

Practical Applications

These patterns aren't just academic exercises—they solve real-world problems. In my projects, I've used conditional types to create self-documenting APIs, template literal types for type-safe routing systems, and branded types to prevent ID mix-ups that could lead to serious bugs.

The key to mastering these patterns is understanding when to apply them. Not every piece of code needs advanced TypeScript features, but when you're building complex systems or libraries that others will use, these patterns can provide invaluable safety and developer experience improvements.

Looking Forward

TypeScript continues to evolve, with each release bringing new capabilities for type-level programming. The patterns we've explored here represent the current state of the art, but the TypeScript team is constantly working on new features that will unlock even more possibilities.

The investment in learning these advanced patterns pays dividends in code quality, maintainability, and developer confidence. Start incorporating them gradually into your projects, and you'll find that TypeScript becomes not just a type checker, but a powerful tool for expressing complex domain logic at the type level.