Development

Advanced TypeScript Patterns for Better Code Quality

FrootsyTech Solutions
6 min read

Quick Answer

Advanced TypeScript patterns like conditional types, mapped types, and discriminated unions help you write more type-safe code. These patterns enable better IDE support, catch errors at compile-time, and make refactoring safer.

Why Advanced TypeScript?

TypeScript's type system is incredibly powerful, but many developers only scratch the surface. Let's explore patterns that will take your TypeScript skills to the next level.

Conditional Types

Conditional types allow you to create types that depend on other types:

// Basic conditional type
type IsString<T> = T extends string ? true : false

type A = IsString<string>  // true
type B = IsString<number>  // false

// Practical example: Extract return type
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never

function getUser() {
  return { id: 1, name: 'John' }
}

type User = ReturnType<typeof getUser>  // { id: number; name: string }

Distributive Conditional Types

Conditional types distribute over union types:

type ToArray<T> = T extends any ? T[] : never

type StrOrNum = string | number
type StrOrNumArray = ToArray<StrOrNum>  // string[] | number[]

Mapped Types

Transform properties of existing types:

// Make all properties optional
type Partial<T> = {
  [P in keyof T]?: T[P]
}

// Make all properties readonly
type Readonly<T> = {
  readonly [P in keyof T]: T[P]
}

// Pick specific properties
type Pick<T, K extends keyof T> = {
  [P in K]: T[P]
}

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

type UserPreview = Pick<User, 'id' | 'name'>
// { id: number; name: string }

Advanced Mapped Types

// Deep partial - makes nested objects optional too
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
}

// Remove readonly modifiers
type Mutable<T> = {
  -readonly [P in keyof T]: T[P]
}

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

Discriminated Unions

Type-safe state management:

type LoadingState = {
  status: 'loading'
}

type SuccessState<T> = {
  status: 'success'
  data: T
}

type ErrorState = {
  status: 'error'
  error: Error
}

type AsyncState<T> = LoadingState | SuccessState<T> | ErrorState

function handleState<T>(state: AsyncState<T>) {
  switch (state.status) {
    case 'loading':
      // TypeScript knows: state = LoadingState
      return 'Loading...'

    case 'success':
      // TypeScript knows: state = SuccessState<T>
      return state.data  // ✓ data exists

    case 'error':
      // TypeScript knows: state = ErrorState
      return state.error.message  // ✓ error exists
  }
}

Type Guards

Custom runtime type checking:

// User-defined type guard
interface Dog {
  bark: () => void
}

interface Cat {
  meow: () => void
}

function isDog(animal: Dog | Cat): animal is Dog {
  return (animal as Dog).bark !== undefined
}

function makeSound(animal: Dog | Cat) {
  if (isDog(animal)) {
    animal.bark()  // TypeScript knows it's a Dog
  } else {
    animal.meow()  // TypeScript knows it's a Cat
  }
}

Advanced Type Guards

// Generic type guard
function isArrayOf<T>(
  arr: unknown,
  check: (item: unknown) => item is T
): arr is T[] {
  return Array.isArray(arr) && arr.every(check)
}

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

const data: unknown = ['a', 'b', 'c']

if (isArrayOf(data, isString)) {
  // TypeScript knows: data is string[]
  data.forEach(str => str.toUpperCase())
}

Template Literal Types

Create sophisticated string types:

// Event system with type safety
type EventName = 'click' | 'focus' | 'blur'
type HandlerName = `on${Capitalize<EventName>}`  // 'onClick' | 'onFocus' | 'onBlur'

type EventHandlers = {
  [K in HandlerName]: () => void
}

// API routes
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
type Resource = 'users' | 'posts' | 'comments'
type Endpoint = `/${Resource}` | `/${Resource}/:id`

// Route handlers
type RouteHandler = {
  [K in `${HTTPMethod} ${Endpoint}`]: (req: Request) => Response
}

Utility Types in Action

Combine multiple patterns:

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

// Non-nullable
type NonNullable<T> = T extends null | undefined ? never : T

// Extract function parameters
type Parameters<T extends (...args: any) => any> =
  T extends (...args: infer P) => any ? P : never

function greet(name: string, age: number) {
  return `Hello ${name}, you are ${age}`
}

type GreetParams = Parameters<typeof greet>  // [string, number]

Branded Types

Create nominal types for additional safety:

// Brand pattern
type Brand<K, T> = K & { __brand: T }

type USD = Brand<number, 'USD'>
type EUR = Brand<number, 'EUR'>

const usd = 100 as USD
const eur = 100 as EUR

// This will error - can't mix currencies!
// const total: USD = usd + eur

// Helper function
function usd(amount: number): USD {
  return amount as USD
}

function eur(amount: number): EUR {
  return amount as EUR
}

Builder Pattern with Types

Fluent interfaces with type safety:

class QueryBuilder<T, Selected extends keyof T = never> {
  private selectFields: Set<keyof T> = new Set()

  select<K extends keyof T>(
    ...fields: K[]
  ): QueryBuilder<T, Selected | K> {
    fields.forEach(f => this.selectFields.add(f))
    return this as any
  }

  build(): Pick<T, Selected> {
    // Implementation
    return {} as Pick<T, Selected>
  }
}

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

const query = new QueryBuilder<User>()
  .select('id', 'name')
  .build()

// Type is: { id: number; name: string }

Best Practices

1. Prefer Type Inference

// ✗ Avoid
const numbers: number[] = [1, 2, 3]

// ✓ Better
const numbers = [1, 2, 3]  // Type inferred as number[]

2. Use unknown Instead of any

// ✗ Avoid
function process(data: any) {
  return data.value
}

// ✓ Better
function process(data: unknown) {
  if (typeof data === 'object' && data !== null && 'value' in data) {
    return (data as { value: unknown }).value
  }
  throw new Error('Invalid data')
}

3. Leverage satisfies Operator

type Route = { path: string; handler: () => void }

const routes = {
  home: { path: '/', handler: () => {} },
  about: { path: '/about', handler: () => {} },
} satisfies Record<string, Route>

// Can still access specific keys
routes.home.path  // ✓ Works, type: string

Conclusion

Advanced TypeScript patterns enable you to:

  • Catch more errors at compile-time
  • Improve IDE autocomplete and refactoring
  • Make impossible states unrepresentable
  • Create more maintainable code

Start incorporating these patterns gradually, and your TypeScript code will become more robust and maintainable.

Further Reading

Free Checklist

Complete SaaS Development Checklist

Download our comprehensive 50-point checklist to build your SaaS product right the first time.

Download Free Checklist

Share this article

FrootsyTech Solutions

FrootsyTech Solutions

Expert Software Development Team

Enterprise Software Development, Cloud Architecture, Full-Stack Engineering

FrootsyTech Solutions is an agile, expert-led software development agency specializing in web and mobile applications. Our team brings decades of combined experience in building scalable, production-ready solutions for businesses worldwide.