Once you understand TypeScript's type system as a programming language in its own right, everything changes. Here are the patterns that made me a better TypeScript developer.
Most TypeScript developers use maybe 20% of the type system. They annotate functions, use interfaces, maybe throw in a generic here and there. But TypeScript's type system is genuinely Turing-complete — you can do computation at the type level. Here's what that unlocks.
Template Literal Types
Template literal types let you construct new string types by combining existing ones. This is incredibly useful for building type-safe event systems, CSS-in-JS APIs, and routing.
type EventName = 'click' | 'focus' | 'blur'
// Capitalize is a built-in TypeScript utility -- turns 'click' into 'Click'
type Handler = `on${Capitalize<EventName>}` // 'onClick' | 'onFocus' | 'onBlur'
// Here's why this matters: the compiler knows every valid prop name
// You can't pass 'onDoubleclick' -- it won't compile
type Side = 'top' | 'right' | 'bottom' | 'left'
type Spacing = `margin-${Side}` | `padding-${Side}`
// 'margin-top' | 'margin-right' | ... | 'padding-left'
// All 8 strings, derived from 2 unions -- no manual list
Discriminated Unions with Exhaustive Checks
The most underused TypeScript pattern. Model your state as a discriminated union and let the compiler tell you when you've forgotten a case.
// Every possible state is explicit -- no boolean flags that combine illegally
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T } // data only exists in success
| { status: 'error'; error: Error } // error only exists in error
// assertNever: if TypeScript lets you reach this line, you missed a case
// It's a compile-time guarantee, not a runtime one
function assertNever(x: never): never {
throw new Error('Unexpected value: ' + x)
}
function render<T>(state: AsyncState<T>) {
switch (state.status) {
case 'idle': return <Idle />
case 'loading': return <Spinner />
case 'success': return <Data value={state.data} /> // state.data is T here, not T | undefined
case 'error': return <ErrorView error={state.error} />
default: return assertNever(state) // Remove a case above -- this line turns red
}
}
Infer in Conditional Types
The infer keyword lets you extract parts of a type within a conditional. This powers utilities like ReturnType, Awaited, and Parameters — and you can build your own.
// 'infer R' means: if T matches Promise<something>, capture that something as R
type Awaited<T> = T extends Promise<infer R> ? R : T
// Same idea -- capture the element type out of an array
type ElementOf<T> = T extends Array<infer E> ? E : never
// Capture just the first argument, ignore the rest
type FirstArg<F> = F extends (first: infer A, ...rest: any[]) => any ? A : never
type A = FirstArg<(name: string, age: number) => void> // string
// These aren't magic -- the compiler checks the shape,
// and infer gives you a name to use on the right-hand side
Run the demos yourself — template literals, discriminated unions, and infer all in one TypeScript file
These three patterns cover 80% of what 'advanced TypeScript' actually means in practice. Template literals for string type composition. Discriminated unions for modelling state correctly. Infer for writing your own type utilities. Once you're comfortable with them, you stop fighting the type system and start using it as a design tool.
Top comments (0)