Ten TypeScript Patterns That Turn Runtime Bugs into Compile Errors
Learn ten TypeScript techniques, from discriminated unions and satisfies to branded types and infer, that let the compiler reject invalid states before your code ships.
This article is published in English.
Sources: Top 10 TypeScript Techniques Senior Developers Secretly Use
Many teams adopt TypeScript by adding annotations to existing JavaScript and stopping there. That catches typos, but leaves most of the type system unused. The bigger win is encoding your rules as types, so whole classes of mistakes fail to compile and no runtime check is needed. Here are ten techniques that do that, with notes on where each one pays off.
Model states explicitly with discriminated unions
A common habit is describing a value as a bag of optional fields and trusting consumers to check the right combination. Nothing below prevents data and error from both being set.
// nothing stops you from having both data and error at once
type ApiResponse = {
data?: User
error?: string
loading?: boolean
}
// consumer has no idea what to check
function handleResponse(res: ApiResponse) {
if (res.data) { /* maybe fine */ }
// what if data AND error are both set?
}
A discriminated union lists the legal shapes instead. Each variant shares a literal field (status), and TypeScript narrows on it: in the success branch res.data is guaranteed, in the error branch res.message is.
type ApiResponse =
| { status: 'loading' }
| { status: 'success'; data: User }
| { status: 'error'; message: string }
function handleResponse(res: ApiResponse) {
switch (res.status) {
case 'loading':
return showSpinner()
case 'success':
// TypeScript KNOWS res.data exists here
return renderUser(res.data)
case 'error':
// TypeScript KNOWS res.message exists here
return showError(res.message)
}
}
When someone later adds a fourth state, any switch that ignores it can become a compile error, especially with the exhaustiveness check shown further down.
Validate objects with satisfies without widening them
Available since TypeScript 4.9, satisfies fixes a subtle problem with annotations. const config: Config = ... checks the object, but the variable's type becomes exactly Config, so config1.theme is the full union rather than 'dark'. With satisfies, the object is still checked (typos in keys are errors), yet the precise inferred type survives: config2.theme stays 'dark' and secondary remains a known key.
type Config = {
theme: 'light' | 'dark' | 'system'
colors: Record<string, string>
}
// with a normal annotation you lose the literal type
const config1: Config = {
theme: 'dark',
colors: { primary: '#3d6fe8' }
}
// config1.theme is now 'light' | 'dark' | 'system', not 'dark'
// with satisfies, the shape is validated but types stay narrow
const config2 = {
theme: 'dark',
colors: { primary: '#3d6fe8', secondary: '#7c3aed' }
} satisfies Config// config2.theme is still 'dark' — the literal type is preserved
// config2.colors.primary is still 'string' — shape is validated
// if you typo a field name, TypeScript still catches it
Annotate when you want the broader type, for instance for a value you will reassign; use satisfies when you want a check plus precise inference.
Generate string unions with template literal types
TypeScript can build string types with backtick syntax. A union in a placeholder expands into every combination, which suits event handler names, CSS custom properties and METHOD /path endpoints.
type EventName<T extends string> = `on${Capitalize<T>}`
// produces: 'onClick' | 'onChange' | 'onSubmit'
type DomEvents = EventName<'click' | 'change' | 'submit'>
// type safe CSS properties
type CSSVar<T extends string> = `--${T}`
type AppVars = CSSVar<'primary' | 'secondary' | 'bg'>
// produces: '--primary' | '--secondary' | '--bg'
// type safe API routes
type HttpMethod = 'GET' | 'POST' | 'DELETE'
type Route = '/users' | '/orders' | '/products'
type ApiEndpoint = `${HttpMethod} ${Route}`
// produces: 'GET /users' | 'GET /orders' | 'POST /users' | ...
function callApi(endpoint: ApiEndpoint) { /* ... */ }
callApi('GET /users') // valid
callApi('PATCH /users') // TypeScript error: not in the type
callApi('PATCH /users') is rejected because PATCH is not in HttpMethod. Combinations multiply fast, so keep input unions small to avoid slowing the compiler.
Separate look-alike values with branded types
TypeScript compares types by structure, so type UserId = string is just string, and a user ID passes happily into a function expecting an order ID. Branding intersects the primitive with a phantom property that never exists at runtime, making the two incompatible. The constructor functions become the only way to create such values and a natural place for validation.
// without branding — TypeScript cannot tell these apart
type UserId = string
type OrderId = string
function deleteOrder(id: OrderId) { /* ... */ }
const userId: UserId = 'user-42'
deleteOrder(userId) // no error - catastrophic bug
// with branded types - structurally different, enforced
type Brand<T, B> = T & { readonly _brand: B }
type UserId = Brand<string, 'UserId'>
type OrderId = Brand<string, 'OrderId'>
// constructor functions act as the only way to create these types
const UserId = (id: string) => id as UserId
const OrderId = (id: string) => id as OrderId
const userId = UserId('user-42')
const orderId = OrderId('order-99')
deleteOrder(userId) // TypeScript error: UserId is not assignable to OrderId
deleteOrder(orderId) // works fine
Good candidates are identifiers (UserId, OrderId, ProductId), checked values such as EmailAddress, HashedPassword or ValidatedInput, and easily confused units like milliseconds versus seconds or dollars versus cents. The snippet declares each alias twice for comparison; keep only the branded version in real code.
Pull types out of other types with infer
Inside a conditional type, infer declares a slot that TypeScript fills by pattern matching. It is how ReturnType, Parameters and Awaited are built, and it lets you write your own extractors.
// extract the return type of any function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never
function getUser() { return { id: 1, name: 'Alice' } }
type User = ReturnType<typeof getUser>
// User = { id: number, name: string }
// extract the resolved value of a Promise
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T
type Data = UnwrapPromise<Promise<User>>
// Data = User
// extract the element type of an array
type ArrayElement<T> = T extends (infer E)[] ? E : never
type Names = ArrayElement<string[]>
// Names = string
// extract first argument of any function
type FirstArg<T> = T extends (first: infer A, ...rest: any[]) => any ? A : never
type Arg = FirstArg<typeof getUser>
// Arg = never (no args), try a fn with args:
type A2 = FirstArg<(id: string) => void>
// A2 = string
Read each definition as "if T matches this shape, what goes in the infer slot?" If the pattern fails, the false branch wins, which is why FirstArg of a zero-argument function is never. Give your own helpers distinct names rather than shadowing the global ReturnType.
Make incomplete switches fail with never
Pair unions with a helper that accepts only never. Once every variant has a case, the type left in default is never and the call compiles. Add hexagon without handling it, and the leftover type breaks the build right there.
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number }
| { kind: 'triangle'; base: number; height: number }
// this helper makes the default case scream at compile time
function assertNever(value: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(value)}`)
}
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2
case 'rectangle':
return shape.width * shape.height
case 'triangle':
return 0.5 * shape.base * shape.height
default:
return assertNever(shape)
// if you add 'hexagon' to Shape and forget to handle it,
// TypeScript throws an error RIGHT HERE at compile time
}
}
Because the helper also throws, it guards against unexpected values arriving from outside the type system, such as API responses.
Reshape types with mapped types and key remapping
Mapped types iterate over the keys of a type to produce a new one, and an as clause renames keys or drops them by mapping to never. The examples build a recursive partial, getter signatures and a filter that keeps only methods.
// make all properties optional but keep their types
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]
}
// prefix all keys with 'get'
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
type User = { id: string; name: string; email: string }
type UserGetters = Getters<User>
// { getId: () => string, getName: () => string, getEmail: () => string }
// pick only function properties from a type
type FunctionKeys<T> = {
[K in keyof T as T[K] extends Function ? K : never]: T[K]
}
type Service = { id: string; save(): void; delete(): void; name: string }
type ServiceMethods = FunctionKeys<Service>
// { save: () => void, delete: () => void }
string & K is needed because keys can also be numbers or symbols, which Capitalize rejects. This simple DeepPartial also recurses into functions and arrays, which is fine for plain data but may need refining.
Teach the compiler your own checks with type guards
TypeScript narrows on typeof, instanceof and literal comparisons, but not inside your custom checks. A type predicate (animal is Cat) declares what a boolean function proves; an assertion function (asserts val is NonNullable<T>) throws on failure and narrows everything after the call.
type Cat = { kind: 'cat'; meows: boolean }
type Dog = { kind: 'dog'; barks: boolean }
type Animal = Cat | Dog
// type predicate - return type tells TypeScript what the guard proves
function isCat(animal: Animal): animal is Cat {
return animal.kind === 'cat'
}
function speak(animal: Animal) {
if (isCat(animal)) {
console.log(animal.meows) // TypeScript knows animal is Cat here
} else {
console.log(animal.barks) // TypeScript knows animal is Dog here
}
}
// assertion function - throws if condition not met, narrows after the call
function assertDefined<T>(val: T): asserts val is NonNullable<T> {
if (val === null || val === undefined) {
throw new Error('Value is null or undefined')
}
}
const user = getUser() // returns User | null
assertDefined(user) // throws at runtime if null
console.log(user.name) // TypeScript knows user is User here, not User | null
The compiler trusts these signatures without verifying them, so a wrong guard misleads every caller. Keep them small and tested.
Keep literal values precise with as const
TypeScript widens literals by default: 'production' becomes string. as const locks an expression to its narrowest types and makes it readonly. The real benefit is deriving unions from data with typeof and indexed access, so the array or object becomes the single source of truth.
// without as const — types are widened
const config = {
env: 'production', // inferred as string, not 'production'
port: 3000, // inferred as number, not 3000
features: ['auth', 'search'] // inferred as string[]
}
// with as const - every value is exactly as written
const config = {
env: 'production',
port: 3000,
features: ['auth', 'search']
} as const
// config.env is 'production', not string
// config.port is 3000, not number
// config.features is readonly ['auth', 'search']
// the real power: derive union types from the values
const ROLES = ['admin', 'editor', 'viewer'] as const
type Role = typeof ROLES[number]
// Role = 'admin' | 'editor' | 'viewer'
// add a new role to the array and the type updates automatically
const ROUTES = {
home: '/',
users: '/users',
profile: '/profile'
} as const
type AppRoute = typeof ROUTES[keyof typeof ROUTES]
// AppRoute = '/' | '/users' | '/profile'
The snippet declares config twice for contrast. With this pattern, adding a role or route updates every dependent type with no duplication.
Vary return and input shapes with conditional types
Conditional types let a generic function return different shapes depending on its arguments, which is how Prisma types its include and select results. Here includePosts: true yields a type with posts, and the second example makes publishedAt required only in publish mode.
// function returns different shapes based on a flag
type UserResponse<T extends boolean> =
T extends true
? { user: User; posts: Post[] } // with posts
: { user: User } // without posts
function fetchUser<T extends boolean>(
id: string,
includePosts: T
): Promise<UserResponse<T>> {
/* implementation */
}
// TypeScript knows exactly what comes back
const result1 = await fetchUser('123', true)
result1.posts // valid - TypeScript knows posts exists
const result2 = await fetchUser('123', false)
result2.posts // TypeScript error - posts does not exist on this return type
// another real world example: optional fields based on mode
type CreateMode = 'draft' | 'publish'
type ArticleInput<M extends CreateMode> =
M extends 'publish'
? { title: string; content: string; publishedAt: Date }
: { title: string; content?: string }
function createArticle<M extends CreateMode>(
mode: M,
input: ArticleInput<M>
) { /* ... */ }
// publishedAt is REQUIRED when mode is 'publish'
createArticle('publish', { title: 'Hey', content: '...', publishedAt: new Date() })
// publishedAt is NOT required when mode is 'draft'
createArticle('draft', { title: 'Hey' })
Inside the implementation, TypeScript usually cannot prove the returned value matches an unresolved conditional type, so expect a cast. For two or three variants, overloads are often clearer.
How the pieces fit together
These are not isolated tricks. Unions define states, never makes handling them exhaustive, as const and template literals generate the unions, and mapped and conditional types derive the rest. Types become a layer of logic rather than documentation. The shorthand below works as a review checklist; it assumes types such as Config, User and Animal exist.
// 01. Discriminated unions — model every valid state explicitly
type State = | { status: 'idle' } | { status: 'ok'; data: T } | { status: 'err'; msg: string }
// 02. satisfies - validate shape without widening literal types
const x = { mode: 'dark' } satisfies Config
// 03. Template literal types - build string unions programmatically
type Event<T extends string> = `on${Capitalize<T>}`
// 04. Branded types - prevent mixing structurally identical primitives
type UserId = string & { readonly _brand: 'UserId' }
// 05. infer - extract types from inside other types
type Unwrap<T> = T extends Promise<infer V> ? V : T
// 06. assertNever - exhaustiveness check at compile time
function assertNever(x: never): never { throw new Error('Unhandled case') }
// 07. Mapped types - transform type shapes programmatically
type Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] }
// 08. Type guards - teach TypeScript how to narrow your types
function isCat(a: Animal): a is Cat { return a.kind === 'cat' }
// 09. as const - lock values to literals, derive union types from data
const ROLES = ['admin', 'editor'] as const
type Role = typeof ROLES[number]
// 10. Conditional types - return different shapes based on input
type Res<T extends boolean> = T extends true ? { data: User; posts: Post[] } : { data: User }
Key takeaways
- Prefer precise shapes over optional fields; unions plus
assertNeverturn missing cases into build failures. - Use
satisfiesandas constto validate values while keeping literal types. - Brand meaningful primitives so IDs and units cannot be swapped.
- Compute types with
infer, mapped and conditional types, but keep them readable. - Treat custom guards and casts as trust boundaries.
For encoding business rules more broadly, see modeling domains in TypeScript.