Discriminated unions, template literal types, infer keyword, satisfies operator, const type parameters, NoInfer utility type — the TypeScript features that sit between “I know TypeScript” and “TypeScript is doing the work for me.” The upgrade path from competent to dangerous.
Most developers who use TypeScript use about 30% of it. They annotate variables with types, they write interfaces for their data, they use generics when a library forces them to, and they put as any or as SomeType when the type system pushes back in a way they don’t understand. That’s not TypeScript working for you. That’s TypeScript working alongside you while you occasionally fight it.
The features in this post are the ones that change the relationship. Not because they’re clever or impressive, but because each one represents the type system doing work that you’d otherwise have to do manually — catching a category of bug, eliminating a class of runtime assertion, or making a refactor that previously required touching twenty files happen automatically when you change one type. They’re all in TypeScript 4.9–5.9. Most teams that use TypeScript daily have never used any of them.
Discriminated Unions — The Feature That Replaces Every status Field Gotcha
The most common modeling mistake in TypeScript is using optional fields to represent state:
// ❌ This compiles. It's wrong.
interface ApiResponse {
data?: User
error?: string
loading?: boolean
}
// The type allows all of these to coexist:
const response: ApiResponse = { data: user, error: 'something failed' }
// Is this a success or an error? The type doesn't know.
// Your runtime code has to guess.
A discriminated union makes impossible states impossible at the type level:
// ✅ Only one state can exist at a time
type ApiResponse =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User }
| { status: 'error'; error: string }
TypeScript narrows the type on every status check. Inside a status === 'success' branch, data is User — not User | undefined. Inside status === 'error', error is string. You can’t access data in the error branch. You can’t access error in the success branch.
function render(response: ApiResponse) {
switch (response.status) {
case 'idle':
return <EmptyState />
case 'loading':
return <Spinner />
case 'success':
return <UserCard user={response.data} /> // data is User, not User | undefined
// ^^^^^ TypeScript knows this exists
case 'error':
return <ErrorBanner message={response.error} /> // error is string
}
}
The exhaustiveness check is the second benefit. Add a new state to the union:
type ApiResponse =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User }
| { status: 'error'; error: string }
| { status: 'stale'; data: User; staleAt: Date } // ← new
Every switch or if/else chain that didn’t handle stale now has a TypeScript error. TypeScript found all the call sites that need updating. You didn’t have to grep.
The never trick enforces exhaustive handling explicitly:
function assertNever(x: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(x)}`)
}
function render(response: ApiResponse) {
switch (response.status) {
case 'idle': return <EmptyState />
case 'loading': return <Spinner />
case 'success': return <UserCard user={response.data} />
case 'error': return <ErrorBanner message={response.error} />
default: return assertNever(response)
// ↑ TypeScript error if any status case is unhandled
// response would be 'stale' here, not never
}
}
Template Literal Types — Strings That TypeScript Can Reason About
Template literal types let you define string shapes at the type level. The practical use isn’t academic — it’s eliminating the class of runtime error where you pass 'user-updated' when the handler expects 'userUpdated'.
// Define the shape of an event name
type EventName<T extends string> = `on${Capitalize<T>}`
type UserEvent = EventName<'click' | 'focus' | 'blur'>
// → 'onClick' | 'onFocus' | 'onBlur'
// ❌ TypeScript error — 'onclick' (lowercase) is not in the union
registerHandler('onclick', handler)
// ✅ Only valid event names accepted
registerHandler('onClick', handler)
The real power is generating types from other types automatically. A route definition that produces type-safe URL parameters:
type Route = '/users/:id' | '/posts/:slug/comments/:commentId' | '/about'
// Extract parameter names from a route string
type ExtractParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractParams<`/${Rest}`>
: T extends `${string}:${infer Param}`
? Param
: never
type UserRouteParams = ExtractParams<'/users/:id'>
// → 'id'
type CommentRouteParams = ExtractParams<'/posts/:slug/comments/:commentId'>
// → 'slug' | 'commentId'
// Type-safe route builder
function buildRoute<T extends Route>(
route: T,
params: Record<ExtractParams<T>, string>
): string {
return Object.entries(params).reduce(
(acc, [key, value]) => acc.replace(`:${key}`, value),
route as string
)
}
buildRoute('/users/:id', { id: '42' }) // ✅
buildRoute('/users/:id', { userId: '42' }) // ❌ 'userId' not in params
buildRoute('/users/:id', {}) // ❌ 'id' is required
The CSS property builder pattern — eliminating invalid class name combinations:
type Size = 'sm' | 'md' | 'lg'
type Color = 'red' | 'blue' | 'green'
type Variant = 'solid' | 'outline' | 'ghost'
// Generated: 'btn-sm-red-solid' | 'btn-sm-red-outline' | ... (27 combinations)
type ButtonClass = `btn-${Size}-${Color}-${Variant}`
function applyClass(cls: ButtonClass): void { /* ... */ }
applyClass('btn-md-blue-solid') // ✅
applyClass('btn-xl-blue-solid') // ❌ 'xl' is not a valid size
applyClass('btn-md-purple-solid') // ❌ 'purple' is not a valid color
The infer Keyword — Extracting Types From Other Types
infer lets you declare a type variable inside a conditional type and capture whatever TypeScript resolves there. The entry-level use is extracting the resolved value from a Promise:
type Awaited<T> = T extends Promise<infer U> ? U : T
type UserResult = Awaited<Promise<User>> // → User
type SyncResult = Awaited<string> // → string (not a Promise, returns as-is)
Awaited<T> is now a built-in TypeScript utility type. infer is how it’s defined under the hood.
The practical application is extracting types from complex return types without duplicating them:
// You have a function that returns a complex type
async function fetchDashboardData() {
return {
user: await fetchUser(),
metrics: await fetchMetrics(),
recentPosts: await fetchRecentPosts(),
}
}
// Extract the return type without duplicating the definition
type DashboardData = Awaited<ReturnType<typeof fetchDashboardData>>
// → { user: User; metrics: Metrics; recentPosts: Post[] }
// Now DashboardData stays in sync with fetchDashboardData automatically.
// Change the function's return type → DashboardData updates automatically.
// No separate interface to maintain.
Extracting parameter types from a function signature:
// Extract the type of the first argument of any function
type FirstParam<T extends (...args: any[]) => any> =
T extends (first: infer F, ...rest: any[]) => any ? F : never
function createUser(data: CreateUserDto): User { /* ... */ }
type UserInput = FirstParam<typeof createUser> // → CreateUserDto
Unwrapping nested generics:
// Extract the element type from any array, regardless of nesting
type ElementType<T> =
T extends (infer E)[] ? ElementType<E> // recurse for nested arrays
: T
type Flat1 = ElementType<string[]> // → string
type Flat2 = ElementType<string[][]> // → string
type Flat3 = ElementType<User[]> // → User
type Flat4 = ElementType<string> // → string (not an array, returns as-is)
satisfies — Type-Check Without Losing Precision
The satisfies operator (TypeScript 4.9) is the solution to a specific and common problem: you want to validate that an object matches a type, but you don’t want TypeScript to widen the type and lose the literal information.
type Config = {
[key: string]: string | number | boolean
}
// ❌ Type annotation — TypeScript widens the type
const config: Config = {
port: 3000,
host: 'localhost',
debug: true,
}
// config.port is now: string | number | boolean
// Not number. Widened to the union.
// Autocomplete for config.port suggests string methods.
// ✅ satisfies — validates against Config, preserves literal types
const config = {
port: 3000,
host: 'localhost',
debug: true,
} satisfies Config
// config.port is: number ← preserved
// config.host is: string ← preserved
// config.debug is: boolean ← preserved
// Typos in keys are caught. Unknown keys are caught. Types are correct.
The as const + satisfies combination is the most powerful pattern:
type HttpStatus = {
[key: string]: { code: number; message: string }
}
// Validates the structure AND makes it immutable AND preserves literal types
const HTTP_STATUS = {
ok: { code: 200, message: 'OK' },
notFound: { code: 404, message: 'Not Found' },
serverError: { code: 500, message: 'Internal Server Error' },
} as const satisfies HttpStatus
// HTTP_STATUS.ok.code is: 200 (literal number, not just number)
// HTTP_STATUS.notFound is readonly
// Adding a key with the wrong shape: TypeScript error
// HTTP_STATUS.ok.code = 201 → Error: Cannot assign to 'code' (readonly)
The Route map pattern — type-safe navigation with preserved string literals:
type RouteConfig = {
[key: string]: { path: string; title: string }
}
const ROUTES = {
home: { path: '/', title: 'Home' },
users: { path: '/users', title: 'Users' },
settings: { path: '/settings', title: 'Settings' },
} satisfies RouteConfig
// ROUTES.home.path is: '/' (literal string, not string)
// TypeScript knows the exact path strings — autocomplete works perfectly
// Accessing a route that doesn't exist: TypeScript error
navigate(ROUTES.home.path) // ✅
navigate(ROUTES.dashboard.path) // ❌ 'dashboard' doesn't exist
The difference from as: satisfies validates without asserting. as SomeType tells TypeScript “trust me, this is SomeType” — it overrides the type checker. satisfies asks TypeScript “does this match SomeType?” — it works with the type checker. Use as only when you genuinely know something TypeScript can’t, typically on external data after runtime validation. Use satisfies for object literals you’re defining yourself.
const Type Parameters — Literal Inference Without as const
Before TypeScript 5.0, generic functions that needed to infer literal types required the caller to add as const:
// Before TypeScript 5.0 — caller had to remember as const
function createRoute<T extends string>(path: T): { path: T } {
return { path }
}
const route = createRoute('/users') // T inferred as: string (widened)
const route2 = createRoute('/users' as const) // T inferred as: '/users' ✅
TypeScript 5.0 introduced const type parameters — add const to the type parameter and the function always infers literal types:
// TypeScript 5.0+ — const type parameter
function createRoute<const T extends string>(path: T): { path: T } {
return { path }
}
const route = createRoute('/users') // T inferred as: '/users' ✅
// No as const required at the call site
The value is most visible in API design — functions that take a configuration object and need to preserve the literal types of the values:
// Without const type parameter — types are widened
function defineConfig<T extends Record<string, unknown>>(config: T): T {
return config
}
const result = defineConfig({
mode: 'production', // inferred as: string (not 'production')
target: 'es2022', // inferred as: string (not 'es2022')
})
// With const type parameter — literals preserved automatically
function defineConfig<const T extends Record<string, unknown>>(config: T): T {
return config
}
const result = defineConfig({
mode: 'production', // inferred as: 'production' ✅
target: 'es2022', // inferred as: 'es2022' ✅
})
For array inputs — discriminated union mapping from an array of strings:
// With const, TypeScript infers the tuple, not string[]
function createEnum<const T extends readonly string[]>(
values: T
): { [K in T[number]]: K } {
return Object.fromEntries(values.map(v => [v, v])) as any
}
const Status = createEnum(['pending', 'active', 'archived'])
// Status.pending is: 'pending'
// Status.active is: 'active'
// Accessing Status.deleted → TypeScript error: property doesn't exist
NoInfer<T> — Controlling the Inference Engine
NoInfer<T> (TypeScript 5.4) solves a specific inference problem: when TypeScript infers a generic type parameter from multiple call sites, a value at one site can influence what’s valid at another site, allowing values you didn’t intend.
// Without NoInfer — T is inferred from both colors AND defaultColor
function createPalette<T extends string>(
colors: T[],
defaultColor?: T,
): { colors: T[]; default: T | undefined } {
return { colors, default: defaultColor }
}
// TypeScript infers T as 'red' | 'blue' | 'purple'
// because 'purple' influences the inference
const palette = createPalette(['red', 'blue'], 'purple')
// No error — but 'purple' isn't in the palette
// With NoInfer — T is inferred only from colors
// defaultColor must be one of the already-inferred T values
function createPalette<T extends string>(
colors: T[],
defaultColor?: NoInfer<T>, // ← excluded from inference
): { colors: T[]; default: T | undefined } {
return { colors, default: defaultColor }
}
// T inferred as: 'red' | 'blue' (from colors only)
// 'purple' is checked against 'red' | 'blue' — type error
const palette = createPalette(['red', 'blue'], 'purple') // ❌
const valid = createPalette(['red', 'blue'], 'red') // ✅
The real-world pattern where NoInfer is most valuable is event systems and type-safe registries:
// Route handler registry — handler type should be inferred from the route definition
// not from the handler itself
type RouteHandler<T extends object> = (params: T) => Response
function createRouter<const Routes extends Record<string, object>>(
routes: Routes,
handlers: {
[K in keyof Routes]: RouteHandler<NoInfer<Routes[K]>>
// ^^^^^^^^ T inferred from Routes, not from handlers
}
) { /* ... */ }
const router = createRouter(
{
'/users/:id': { id: '' as string },
'/posts/:slug': { slug: '' as string },
},
{
'/users/:id': ({ id }) => new Response(`User ${id}`),
// id is: string — inferred from the route definition
'/posts/:slug': ({ slug }) => new Response(`Post ${slug}`),
// slug is: string — inferred from the route definition
}
)
Putting Them Together — A Type-Safe Event Bus
These features compose. A type-safe event bus that uses discriminated unions for events, template literal types for namespacing, satisfies for the event map, and NoInfer for the listener registry:
// Define events as a discriminated union
type AppEvent =
| { type: 'user:created'; payload: { userId: string; email: string } }
| { type: 'user:deleted'; payload: { userId: string } }
| { type: 'order:placed'; payload: { orderId: string; total: number } }
| { type: 'order:shipped'; payload: { orderId: string; carrier: string } }
// Template literal: namespace prefix is always 'user:' or 'order:'
type EventType = AppEvent['type'] // 'user:created' | 'user:deleted' | 'order:placed' | 'order:shipped'
// Extract payload type for a given event type using infer
type PayloadFor<T extends EventType> = Extract<AppEvent, { type: T }>['payload']
// Type-safe emit and subscribe
class EventBus {
private listeners = new Map<string, Set<Function>>()
emit<T extends EventType>(type: T, payload: PayloadFor<T>): void {
this.listeners.get(type)?.forEach(fn => fn(payload))
}
on<T extends EventType>(
type: T,
listener: (payload: NoInfer<PayloadFor<T>>) => void
): () => void {
if (!this.listeners.has(type)) {
this.listeners.set(type, new Set())
}
this.listeners.get(type)!.add(listener)
return () => this.listeners.get(type)?.delete(listener)
}
}
const bus = new EventBus()
// ✅ Payload type is inferred from the event type
bus.emit('user:created', { userId: '1', email: 'sadique@example.com' })
bus.emit('order:placed', { orderId: '99', total: 149_00 })
// ❌ Wrong payload shape — TypeScript error
bus.emit('user:created', { orderId: '99' })
// ✅ Listener receives correctly typed payload
bus.on('order:shipped', ({ orderId, carrier }) => {
// orderId: string, carrier: string — both typed correctly
console.log(`Order ${orderId} shipped via ${carrier}`)
})
// ❌ Accessing a field that doesn't exist on this event's payload
bus.on('user:deleted', ({ email }) => { /* ❌ 'email' doesn't exist on user:deleted */ })
The event bus knows the payload shape for every event type. The listener receives the correct payload. Wrong payload shapes, accessing nonexistent fields, emitting with the wrong data — all type errors.
The Mental Shift
The features in this post share a structure: instead of writing a runtime check and hoping it’s correct, you describe the constraint to TypeScript and let the compiler enforce it. Discriminated unions mean impossible states can’t be represented. Template literal types mean invalid string shapes can’t be passed. satisfies means misconfigured objects can’t reach production. infer means type definitions don’t drift from implementations.
The upgrade from “I know TypeScript” to “TypeScript is doing the work for me” is the upgrade from annotating code to designing types. Annotations document what exists. Types constrain what can exist. The compiler enforces the constraint on every call site, in every file, automatically.
That’s not a clever trick. It’s the feature.
