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