Why Advanced TypeScript Pays Off

TypeScript's basic types — string, number, boolean, interfaces — eliminate most runtime type errors. But the advanced type system allows you to encode business logic at the type level: making invalid states unrepresentable, auto-completing API shapes, and catching contract violations at compile time rather than in production logs. This guide covers the patterns that distinguish TypeScript power users from beginners.

Generic Constraints: Precise Type Bounds

Generics with constraints (extends) let you write functions that operate on a range of types while retaining knowledge about the specific type passed in. Without constraints, TypeScript cannot know what properties the type has:

// Without constraint — TypeScript doesn't know T has .length
function logLength<T>(value: T): void {
  console.log(value.length) // Error: Property 'length' does not exist on type 'T'
}

// With constraint — now T is guaranteed to have .length
function logLength<T extends { length: number }>(value: T): T {
  console.log(value.length)
  return value // Returns T (the specific type), not just { length: number }
}

logLength('hello')        // string — .length is 5
logLength([1, 2, 3])      // number[] — .length is 3
logLength({ length: 42 }) // object — .length is 42

// Multiple constraints using intersection
function mergeAndLog<T extends object, U extends object>(a: T, b: U): T & U {
  const result = { ...a, ...b } as T & U
  console.log(result)
  return result
}

// keyof constraint — key must be a key of the object
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key]
}

const user = { id: 1, name: 'Alice', email: 'alice@example.com' }
const name = getProperty(user, 'name')   // TypeScript knows this is string
const id   = getProperty(user, 'id')     // TypeScript knows this is number
// getProperty(user, 'missing')           // Error: 'missing' not in keyof typeof user

Conditional Types

Conditional types evaluate the type based on a condition: T extends U ? X : Y. This is TypeScript's type-level ternary operator, and it enables powerful transformations:

// Basic conditional type
type IsArray<T> = T extends any[] ? true : false

type A = IsArray<string[]> // true
type B = IsArray<string>   // false

// Unwrap an array element type
type ElementType<T> = T extends (infer E)[] ? E : never

type C = ElementType<string[]>  // string
type D = ElementType<number[]>  // number
type E = ElementType<string>    // never (not an array)

// Unwrap Promise type (useful for async returns)
type Awaited<T> = T extends Promise<infer R> ? Awaited<R> : T
// Note: Awaited is now built into TypeScript 4.5+

// Practical: extract the return type of an async function
async function fetchUser(id: string) {
  return { id, name: 'Alice', role: 'admin' as const }
}
type UserData = Awaited<ReturnType<typeof fetchUser>>
// { id: string; name: string; role: "admin" }

// Distribute over union types
type NonNullable<T> = T extends null | undefined ? never : T
// NonNullable<string | null | undefined> → string

Mapped Types

Mapped types transform each property of an existing type. They are the engine behind TypeScript's built-in utility types like Partial, Required, and Readonly:

// Partial<T> — make every property optional
type Partial<T> = { [K in keyof T]?: T[K] }

// Required<T> — make every property required
type Required<T> = { [K in keyof T]-?: T[K] }  // -? removes optionality

// Readonly<T> — make every property readonly
type Readonly<T> = { readonly [K in keyof T]: T[K] }

// Custom: make specific keys optional
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>

interface CreateUserInput {
  name: string
  email: string
  role: string
  avatarUrl: string  // optional on creation
}
type CreateUserDTO = PartialBy<CreateUserInput, 'avatarUrl' | 'role'>
// { name: string; email: string; role?: string; avatarUrl?: string }

// Remapping keys with 'as' (TypeScript 4.1+)
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
interface User { id: number; name: string }
type UserGetters = Getters<User>
// { getId: () => number; getName: () => string }

Template Literal Types

Template literal types (TypeScript 4.1+) construct string types by combining string literals and type variables — enabling precise string-shaped types:

type Direction = 'top' | 'right' | 'bottom' | 'left'
type CSSPadding = `padding-${Direction}`
// "padding-top" | "padding-right" | "padding-bottom" | "padding-left"

type EventName = `on${Capitalize<string>}`
// "onClick", "onChange", etc.

// Practical: typed event emitter
type EventMap = {
  userCreated: { userId: string }
  orderPlaced: { orderId: string; total: number }
  paymentFailed: { orderId: string; reason: string }
}

type EventHandler<T extends keyof EventMap> = (data: EventMap[T]) => void

function on<T extends keyof EventMap>(event: T, handler: EventHandler<T>): void {
  // implementation
}

on('userCreated', ({ userId }) => console.log(userId))
// on('unknown', () => {})   // Error: 'unknown' not in EventMap

Discriminated Unions

Discriminated unions (also called tagged unions) are the idiomatic TypeScript pattern for modeling states where a value can be one of several shapes. A shared "discriminant" property (usually type or status) narrows the type in switch statements and if-checks:

type ApiResult<T> =
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string; code: number }

function handleResult<T>(result: ApiResult<T>): string {
  switch (result.status) {
    case 'loading': return 'Loading...'
    case 'success': return `Got ${JSON.stringify(result.data)}`  // result.data is T here
    case 'error':   return `Error ${result.code}: ${result.error}`  // result.error + .code available
  }
}

// Pattern: exhaustive check — TypeScript errors if a case is unhandled
function assertNever(x: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(x)}`)
}

function describe(result: ApiResult<unknown>): string {
  switch (result.status) {
    case 'loading': return 'loading'
    case 'success': return 'success'
    case 'error':   return 'error'
    default:        return assertNever(result) // Compile error if a case is missing
  }
}

Key Utility Types Cheat Sheet

// Structural
Partial<T>           // All properties optional
Required<T>          // All properties required
Readonly<T>          // All properties readonly
Record<K, V>         // Object with keys K and values V

// Extraction
Pick<T, K>           // Keep only keys K from T
Omit<T, K>           // Remove keys K from T
Extract<T, U>        // Extract members of T assignable to U
Exclude<T, U>        // Remove members of T assignable to U
NonNullable<T>       // Remove null and undefined from T

// Inference
ReturnType<F>        // Return type of function F
Parameters<F>        // Parameters tuple of function F
InstanceType<C>      // Instance type of constructor C
Awaited<T>           // Unwrap nested Promise

// Example: derive update DTO from full type
interface User { id: string; name: string; email: string; createdAt: Date }
type UpdateUserDTO = Partial<Omit<User, 'id' | 'createdAt'>>
// { name?: string; email?: string }

The satisfies Operator (TypeScript 4.9+)

The satisfies operator validates that a value matches a type without widening the type. This is the solution when you want type checking but also want TypeScript to retain the narrowest possible type for autocomplete and inference:

const palette = {
  red: [255, 0, 0],
  green: '#00ff00',
  blue: [0, 0, 255],
} satisfies Record<string, string | number[]>

// Without satisfies, palette.red would be string | number[]
// With satisfies, TypeScript knows palette.red is number[] and palette.green is string
const redChannel = palette.red[0]    // number — TypeScript knows it's an array
const greenUpper = palette.green.toUpperCase() // string method — TypeScript knows it's a string