TypeScript's type system is remarkably powerful, but most codebases barely scratch the surface. In this article, we will explore advanced patterns that make production TypeScript code safer, more expressive, and easier to refactor.
Discriminated Unions
Discriminated unions are the single most useful TypeScript pattern for modeling state. Instead of using optional fields or type assertions, you give each variant a literal discriminant:
// Instead of this (fragile):
type ApiResponse<T> = {
data?: T
error?: string
loading?: boolean
}
// Do this (type-safe):
type ApiResponse<T> =
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string }
function handleResponse<T>(response: ApiResponse<T>) {
switch (response.status) {
case 'loading':
return <Spinner />
case 'success':
return <DataView data={response.data} />
case 'error':
return <ErrorMessage message={response.error} />
}
}
The compiler ensures you handle every case. Add a new variant? You get a compile error if you forget to handle it. No more undefined is not a function at runtime.
Template Literal Types
Template literal types let you build string types from other types, which is incredibly useful for APIs, CSS, and event systems:
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
type ApiVersion = 'v1' | 'v2'
// Generates: '/api/v1/users' | '/api/v2/users' | '/api/v1/posts' | ...
type ApiEndpoint = /api/${ApiVersion}/${'users' | 'posts' | 'comments'}
// Event handler naming convention
type EventName = 'click' | 'hover' | 'focus'
type EventHandler = on${Capitalize<EventName>}
// Result: 'onClick' | 'onHover' | 'onFocus'
// Type-safe CSS units
type CSSUnit = 'px' | 'rem' | 'em' | '%' | 'vh' | 'vw'
type CSSValue = ${number}${CSSUnit}
const width: CSSValue = '100px' // valid
const bad: CSSValue = '100' // error: missing unit
Conditional Types
Conditional types let you create types that depend on other types. They are the foundation of many built-in utility types:
// Basic conditional type
type IsString<T> = T extends string ? true : false
type A = IsString<'hello'> // true
type B = IsString<42> // false
// Extract the return type of async functions
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T
type Result = UnwrapPromise<Promise<string>> // string
type Plain = UnwrapPromise<number> // number
// Practical example: extract props from a React component
type PropsOf<C> = C extends React.ComponentType<infer P> ? P : never
type ButtonProps = PropsOf<typeof Button> // extracts Button's prop type
Practical Utility Types
Here are utility types I use constantly in production code:
// Make specific keys required (opposite of Partial)
type RequireKeys<T, K extends keyof T> = T & Required<Pick<T, K>>
type User = {
id?: string
name?: string
email?: string
}
// CreateUser requires 'name' and 'email', but 'id' stays optional
type CreateUser = RequireKeys<User, 'name' | 'email'>
// Deep readonly -- prevents mutation at any nesting level
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K]
}
type Config = DeepReadonly<{
database: {
host: string
port: number
credentials: { user: string; pass: string }
}
}>
// This errors: cannot mutate nested objects
// config.database.port = 3000
// Exhaustive switch helper
function assertNever(value: never): never {
throw new Error(Unexpected value: ${value})
}
// Use with discriminated unions
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number }
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI shape.radius 2
case 'rectangle':
return shape.width shape.height
default:
return assertNever(shape) // compile error if a case is missing
}
}
Branded Types
TypeScript uses structural typing, which means string and string are interchangeable. Branded types let you create nominally-distinct types:
type Brand<T, B extends string> = T & { readonly __brand: B }
type UserId = Brand<string, 'UserId'>
type OrderId = Brand<string, 'OrderId'>
function getUser(id: UserId) { / ... / }
function getOrder(id: OrderId) { / ... / }
const userId = 'user-123' as UserId
const orderId = 'order-456' as OrderId
getUser(userId) // valid
getUser(orderId) // error: OrderId is not assignable to UserId
This prevents accidentally passing an order ID where a user ID is expected -- a bug that structural typing alone cannot catch.
Key Takeaways
- Discriminated unions replace optional fields and type assertions for modeling state.
- Template literal types give you type-safe strings for APIs, CSS, and naming conventions.
- Conditional types unlock powerful type transformations and inference.
- Branded types add nominal typing on top of TypeScript's structural system, preventing ID mix-ups.
- These patterns pay for themselves in reduced runtime bugs and safer refactoring.
Crafted by
Abiyyu Abidiffatir Al MajidSoftware Engineer passionate about building scalable web applications and sharing knowledge about modern web development, system design, and emerging technologies.



