Farhan Sadeek

Student

Mastering TypeScript: Advanced Types and Patterns

TypeScript has evolved far beyond simple type annotations. Modern TypeScript offers powerful features that can help you write more robust, maintainable, and expressive code. Let's explore advanced patterns that will elevate your TypeScript skills.

Advanced Type Patterns

Conditional Types

Conditional types allow you to create types that change based on conditions:

type NonNullable<T> = T extends null | undefined ? never : T

type ApiResponse<T> = T extends string
  ? { message: T }
  : T extends number
    ? { code: T }
    : { data: T }

// Usage
type StringResponse = ApiResponse<string> // { message: string }
type NumberResponse = ApiResponse<number> // { code: number }
type ObjectResponse = ApiResponse<User> // { data: User }

Mapped Types

Create new types by transforming existing ones:

type Partial<T> = {
  [P in keyof T]?: T[P]
}

type Required<T> = {
  [P in keyof T]-?: T[P]
}

// Custom mapped type
type Stringify<T> = {
  [K in keyof T]: string
}

interface User {
  id: number
  name: string
  email: string
}

type UserStrings = Stringify<User>
// Result: { id: string; name: string; email: string }

Template Literal Types

Build types using string templates:

type EventName<T extends string> = `on${Capitalize<T>}`
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
type APIEndpoint<T extends HTTPMethod> = `${Lowercase<T>} /api/`

// Usage
type ClickHandler = EventName<'click'> // 'onClick'
type SubmitHandler = EventName<'submit'> // 'onSubmit'
type GetEndpoint = APIEndpoint<'GET'> // 'get /api/'

Utility Type Patterns

Deep Readonly

Create a deeply readonly version of any type:

type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P]
}

interface Config {
  database: {
    host: string
    port: number
    credentials: {
      username: string
      password: string
    }
  }
}

type ReadonlyConfig = DeepReadonly<Config>
// All properties and nested properties are readonly

Pick and Omit Combinations

// Extract specific properties and make them optional
type PartialPick<T, K extends keyof T> = Partial<Pick<T, K>>

// Exclude properties and make remaining required
type RequiredOmit<T, K extends keyof T> = Required<Omit<T, K>>

interface User {
  id: number
  name: string
  email: string
  password: string
  createdAt: Date
}

type UserUpdateData = PartialPick<User, 'name' | 'email'>
// { name?: string; email?: string }

type PublicUser = RequiredOmit<User, 'password'>
// { id: number; name: string; email: string; createdAt: Date }

Advanced Function Patterns

Function Overloads with Generics

interface Repository<T> {
  findById(id: string): Promise<T | null>
  findById(id: string[]): Promise<T[]>
  findById(id: string | string[]): Promise<T | T[] | null>
}

class UserRepository implements Repository<User> {
  async findById(id: string): Promise<User | null>
  async findById(id: string[]): Promise<User[]>
  async findById(id: string | string[]): Promise<User | User[] | null> {
    if (Array.isArray(id)) {
      return this.findMultiple(id)
    }
    return this.findSingle(id)
  }
}

Higher-Order Type Functions

type ReturnTypeOf<T> = T extends (...args: any[]) => infer R ? R : never
type ParametersOf<T> = T extends (...args: infer P) => any ? P : never

// Create a type that wraps return values
type AsyncReturn<T extends (...args: any[]) => any> = (
  ...args: ParametersOf<T>
) => Promise<ReturnTypeOf<T>>

function syncFunction(x: number, y: string): boolean {
  return x > 0 && y.length > 0
}

type AsyncVersion = AsyncReturn<typeof syncFunction>
// (x: number, y: string) => Promise<boolean>

Advanced Class Patterns

Mixins with TypeScript

type Constructor<T = {}> = new (...args: any[]) => T

function Timestamped<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    timestamp = Date.now()

    getAge() {
      return Date.now() - this.timestamp
    }
  }
}

function Activatable<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    isActive = false

    activate() {
      this.isActive = true
    }

    deactivate() {
      this.isActive = false
    }
  }
}

class User {
  constructor(public name: string) {}
}

const TimestampedUser = Timestamped(User)
const ActiveUser = Activatable(TimestampedUser)

const user = new ActiveUser('John')
user.activate()
console.log(user.getAge())

Abstract Factory Pattern

abstract class DatabaseConnection {
  abstract connect(): Promise<void>
  abstract query(sql: string): Promise<any[]>
  abstract close(): Promise<void>
}

class PostgreSQLConnection extends DatabaseConnection {
  async connect() {
    // PostgreSQL specific connection
  }

  async query(sql: string) {
    // PostgreSQL specific query
    return []
  }

  async close() {
    // PostgreSQL specific cleanup
  }
}

class MySQLConnection extends DatabaseConnection {
  async connect() {
    // MySQL specific connection
  }

  async query(sql: string) {
    // MySQL specific query
    return []
  }

  async close() {
    // MySQL specific cleanup
  }
}

type DatabaseType = 'postgresql' | 'mysql'

class DatabaseFactory {
  static create(type: DatabaseType): DatabaseConnection {
    switch (type) {
      case 'postgresql':
        return new PostgreSQLConnection()
      case 'mysql':
        return new MySQLConnection()
      default:
        throw new Error(`Unsupported database type: ${type}`)
    }
  }
}

Type-Safe Event System

interface EventMap {
  'user:created': { user: User }
  'user:updated': { user: User; changes: Partial<User> }
  'user:deleted': { userId: string }
}

class TypedEventEmitter<T extends Record<string, any>> {
  private listeners: {
    [K in keyof T]?: Array<(data: T[K]) => void>
  } = {}

  on<K extends keyof T>(event: K, listener: (data: T[K]) => void) {
    if (!this.listeners[event]) {
      this.listeners[event] = []
    }
    this.listeners[event]!.push(listener)
  }

  emit<K extends keyof T>(event: K, data: T[K]) {
    const eventListeners = this.listeners[event]
    if (eventListeners) {
      eventListeners.forEach((listener) => listener(data))
    }
  }
}

const eventEmitter = new TypedEventEmitter<EventMap>()

// Type-safe event listening
eventEmitter.on('user:created', (data) => {
  // data is correctly typed as { user: User }
  console.log('User created:', data.user)
})

// Type-safe event emission
eventEmitter.emit('user:updated', {
  user: someUser,
  changes: { name: 'New Name' },
})

Best Practices

1. Use Type Guards Effectively

function isString(value: unknown): value is string {
  return typeof value === 'string'
}

function isUser(obj: any): obj is User {
  return obj && typeof obj.id === 'number' && typeof obj.name === 'string'
}

// Usage
function processData(data: unknown) {
  if (isString(data)) {
    // TypeScript knows data is string here
    return data.toUpperCase()
  }

  if (isUser(data)) {
    // TypeScript knows data is User here
    return `Hello, ${data.name}`
  }
}

2. Leverage const Assertions

const themes = ['light', 'dark', 'auto'] as const
type Theme = (typeof themes)[number] // 'light' | 'dark' | 'auto'

const config = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
  retries: 3,
} as const

type Config = typeof config
// {
//   readonly apiUrl: 'https://api.example.com'
//   readonly timeout: 5000
//   readonly retries: 3
// }

3. Use Branded Types for Better Type Safety

type UserId = string & { readonly brand: unique symbol }
type Email = string & { readonly brand: unique symbol }

function createUserId(id: string): UserId {
  // Validation logic here
  return id as UserId
}

function createEmail(email: string): Email {
  // Email validation logic here
  return email as Email
}

function sendEmail(userId: UserId, email: Email) {
  // Implementation
}

// This prevents mixing up string types
const id = createUserId('123')
const email = createEmail('user@example.com')
sendEmail(id, email) // ✅ Correct
// sendEmail(email, id) // ❌ Type error

Conclusion

Advanced TypeScript patterns enable you to:

  • Write more expressive and type-safe code
  • Catch errors at compile time rather than runtime
  • Create reusable and maintainable abstractions
  • Build better APIs with excellent developer experience

These patterns might seem complex at first, but they become invaluable tools as your applications grow in complexity. Start incorporating them gradually, and you'll find your TypeScript code becoming more robust and maintainable.

Remember: the goal isn't to use every advanced feature, but to choose the right patterns that solve real problems in your codebase.