Production TypeScript patterns: const type parameters, satisfies operator, template literal types for API routes, branded types for domain safety, discriminated unions, and the builder pattern with full type inference.
Tyler McDaniel
AI Engineer & IBM Business Partner
If you're writing application code, TypeScript's type system is a safety net. If you're writing library code, it's a design tool. The difference matters because library consumers can't edit your types — they have to live with whatever inference you've set up, and bad inference means bad DX. TypeScript 5.x introduced patterns that fundamentally change how you can design library APIs: const type parameters, satisfies for config validation, template literal types for string APIs, and a matured approach to branded types that makes impossible states genuinely unrepresentable.
I maintain two published TypeScript libraries and contribute to a third. These are the advanced patterns I reach for when the type system needs to work as hard as the runtime code.
Before TypeScript 5.0, passing an object literal to a generic function widened the types. Strings became string, numbers became number, arrays became mutable arrays. If your library's API depended on the exact literal values — think route definitions, event names, config keys — you lost that information.
// Without const type parameter — types widen
function defineRoutes<T extends Record<string, string>>(routes: T): T {
return routes;
}
const routes = defineRoutes({
home: '/home',
about: '/about',
blog: '/blog/:slug',
});
// Type: { home: string; about: string; blog: string }
// We lost the literal '/home', '/about', '/blog/:slug'
With const type parameters (TypeScript 5.0+):
function defineRoutes<const T extends Record<string, string>>(routes: T): T {
return routes;
}
const routes = defineRoutes({
home: '/home',
about: '/about',
blog: '/blog/:slug',
});
// Type: { readonly home: "/home"; readonly about: "/about"; readonly blog: "/blog/:slug" }
// Literal types preserved. Now we can extract route params from the strings.
The const modifier tells TypeScript to infer T as if the argument were declared with as const. This is the foundation for builder-pattern APIs where the return type needs to reflect the exact input.
Real-world use case — a type-safe event emitter:
function createEmitter<const TEvents extends Record<string, unknown[]>>() {
type EventMap = { [K in keyof TEvents]: (...args: TEvents[K]) => void };
const handlers = new Map<keyof TEvents, Set<Function>>();
return {
on<K extends keyof TEvents>(event: K, handler: (...args: TEvents[K]) => void) {
if (!handlers.has(event)) handlers.set(event, new Set());
handlers.get(event)!.add(handler);
},
emit<K extends keyof TEvents>(event: K, ...args: TEvents[K]) {
handlers.get(event)?.forEach((handler) => handler(...args));
},
};
}
const bus = createEmitter<{
'user:login': [userId: string, timestamp: number];
'user:logout': [userId: string];
'error': [error: Error, context: string];
}>();
bus.on('user:login', (userId, timestamp) => {
// userId: string, timestamp: number — fully typed
console.log(${userId} logged in at ${timestamp});
});
bus.emit('user:login', 'abc', Date.now()); // OK
bus.emit('user:login', 123); // Error: number is not assignable to string
bus.emit('unknown-event'); // Error: not in event map
satisfies Operator for Config Validationsatisfies (TypeScript 4.9+) validates that a value matches a type without widening it. This is the correct tool for library configuration objects where you need both type safety and literal inference.
interface ThemeConfig {
colors: Record<string, string>;
spacing: Record<string, string | number>;
breakpoints: Record<string, string>;
}
// With satisfies, the value is validated against ThemeConfig
// but the type is inferred from the literal
const theme = {
colors: {
primary: 'oklch(0.65 0.24 265)',
secondary: 'oklch(0.55 0.18 150)',
surface: 'oklch(0.98 0.01 265)',
error: 'oklch(0.55 0.22 25)',
},
spacing: {
xs: '0.25rem',
sm: '0.5rem',
md: '1rem',
lg: '2rem',
xl: '4rem',
},
breakpoints: {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
},
} satisfies ThemeConfig;
// theme.colors.primary is 'oklch(0.65 0.24 265)' (literal), not string
// theme.spacing.xs is '0.25rem' (literal), not string | number
// But if you typo a property name or use wrong types, you get an error:
const badTheme = {
colors: {
primary: 42, // Error: number is not assignable to string
},
spacing: {},
breakpoints: {},
} satisfies ThemeConfig;
Compare with type annotation:
// With type annotation — widened, you lose literals
const annotatedTheme: ThemeConfig = {
colors: { primary: 'oklch(0.65 0.24 265)' },
spacing: {},
breakpoints: {},
};
// annotatedTheme.colors.primary is string, not the literal
For library config APIs, always use satisfies in your documentation examples. Users get autocompletion and validation during authoring, plus literal types for downstream consumption.
Template literal types let you parse and validate string patterns at the type level. This is incredibly powerful for APIs where strings have structure — routes, CSS selectors, event names, SQL fragments.
// Extract route parameters from a path pattern
type ExtractParams<T extends string> =
T extends ${string}:${infer Param}/${infer Rest}
? Param | ExtractParams<Rest>
: T extends ${string}:${infer Param}
? Param
: never;
type BlogParams = ExtractParams<'/blog/:slug'>; // "slug"
type UserParams = ExtractParams<'/users/:userId/posts/:postId'>; // "userId" | "postId"
// Type-safe route handler
function createRoute<const T extends string>(
path: T,
handler: (params: Record<ExtractParams<T>, string>) => Response
): void {
// Runtime implementation parses the path and extracts params
}
createRoute('/users/:userId/posts/:postId', (params) => {
// params.userId: string ✓
// params.postId: string ✓
// params.foo ← Error: 'foo' does not exist
return new Response(User ${params.userId}, Post ${params.postId});
});
I use this pattern for CSS utility generation:
type CSSProperty = 'margin' | 'padding' | 'gap';
type CSSDirection = 'top' | 'right' | 'bottom' | 'left' | 'x' | 'y';
type SpacingScale = '0' | '1' | '2' | '4' | '8' | '16';
type SpacingUtility = ${CSSProperty}-${CSSDirection}-${SpacingScale};
// "margin-top-0" | "margin-top-1" | ... | "gap-y-16"
// That's 6 × 6 × 6 = 216 valid utility strings, enforced at compile time.
The limitation: template literal types create union types, and TypeScript has a [hardcoded limit](https://github.com/microsoft/TypeScript/pull/40336) of ~100,000 union members. If your combinatorial explosion exceeds that, the type collapses to string. Design your patterns to stay within reasonable bounds.
Branded types (also called nominal types or opaque types) prevent mixing values that have the same runtime representation but different semantic meanings:
declare const __brand: unique symbol;type Brand<T, B extends string> = T & { readonly [__brand]: B };
type UserId = Brand<string, 'UserId'>;
type CourseId = Brand<string, 'CourseId'>;
type Email = Brand<string, 'Email'>;
function createUserId(raw: string): UserId {
if (raw.length === 0) throw new Error('Empty user ID');
return raw as UserId;
}
function createEmail(raw: string): Email {
if (!raw.includes('@')) throw new Error('Invalid email');
return raw as Email;
}
function sendEmail(to: Email, subject: string): void {
// ...
}
function getUser(id: UserId): void {
// ...
}
const userId = createUserId('user-123');
const email = createEmail('jane@example.com');
sendEmail(email, 'Welcome'); // OK
sendEmail(userId, 'Welcome'); // Error: UserId is not assignable to Email
getUser(userId); // OK
getUser(email); // Error: Email is not assignable to UserId
At runtime, UserId and Email are both just strings. There's zero overhead. But at compile time, TypeScript treats them as incompatible types. This prevents the entire class of bugs where you accidentally pass a course ID where a user ID was expected.
For [LTI 1.3 integrations](https://tostupidtooquit.com/blog/understanding-lti-13-integration), I brand every ID type — PlatformId, ClientId, LaunchId, Nonce — because mixing them up produces silent failures that are nightmarish to debug.
Discriminated unions with exhaustiveness checking ensure every case is handled:
type APIResponse =
| { status: 'success'; data: unknown; cached: boolean }
| { status: 'error'; error: string; retryable: boolean }
| { status: 'loading' }
| { status: 'idle' };
function handleResponse(response: APIResponse): string {
switch (response.status) {
case 'success':
return Data: ${JSON.stringify(response.data)};
case 'error':
return Error: ${response.error};
case 'loading':
return 'Loading...';
case 'idle':
return 'Ready';
default:
// If you add a new status to APIResponse and forget to handle it,
// this line fails to compile because response is not never
const _exhaustive: never = response;
return _exhaustive;
}
}
This pattern is essential in library code because it catches missing cases at compile time when you evolve your types. If you add { status: 'rate-limited'; retryAfter: number } to APIResponse, every switch over status that doesn't handle it will fail to compile.
I combine this with a helper:
function assertNever(value: never, message?: string): never {
throw new Error(message ?? Unexpected value: ${JSON.stringify(value)});
}
// In the switch:
default:
assertNever(response, Unhandled response status: ${(response as any).status});
The runtime error message helps during development when a new case is added but not yet handled everywhere.
Conditional types power the most sophisticated API designs — where the return type changes based on the input type:
interface FetchOptions {
format?: 'json' | 'text' | 'blob';
}
type FetchResult<T extends FetchOptions> =
T extends { format: 'text' } ? string :
T extends { format: 'blob' } ? Blob :
unknown; // Default to unknown for 'json' (user provides the type)
async function fetchData<T extends FetchOptions>(
url: string,
options?: T
): Promise<FetchResult<T>> {
const response = await fetch(url);
const format = options?.format ?? 'json';
if (format === 'text') return response.text() as Promise<FetchResult<T>>;
if (format === 'blob') return response.blob() as Promise<FetchResult<T>>;
return response.json() as Promise<FetchResult<T>>;
}
// Usage:
const json = await fetchData('/api/users'); // unknown
const text = await fetchData('/api/readme', { format: 'text' }); // string
const blob = await fetchData('/api/image', { format: 'blob' }); // Blob
The internal casts (as Promise) are the necessary trade-off. TypeScript can't narrow generic types through control flow analysis, so the implementation needs casts while the public API stays fully typed.
When you publish a library, tsc generates .d.ts files from your source. These declaration files are what consumers see. Several patterns cause problems:
Inlined complex types. If your return type involves deep conditional types, the emitted declaration can be enormous and slow down consumers' IDEs. Simplify public return types with explicit type aliases.
Private type dependencies. If an internal type leaks into a public API's declaration, consumers get an error about a type they can't import. Use @internal tags and verify declarations with [api-extractor](https://api-extractor.com/) or manual inspection.
isolatedDeclarations (TypeScript 5.5+). This flag requires explicit return type annotations on exported functions. It's strict, but it makes declaration emit predictable and enables parallel declaration generation during builds. If you're starting a new library, enable it:
{
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"isolatedDeclarations": true
}
}
| Feature | TypeScript | Flow | JSDoc Types |
|---------|-----------|------|-------------|
| Adoption | Dominant (>90% of typed JS) | Meta only (declining) | Growing (used by Svelte, some libs) |
| Build step required | Yes (tsc, esbuild, swc) | Yes (flow-remove-types) | No (comments stripped at parse) |
| IDE support | Excellent (VSCode native) | Decent (VSCode extension) | Good (VSCode IntelliSense) |
| Conditional types | Full support | Limited | No |
| Template literal types | Yes | No | No |
| Branded types | Via intersection | Via opaque types (native!) | Limited |
| Ecosystem | npm standard | Internal to Meta | Works with plain JS |
| Declaration files | .d.ts (standard) | .flow (non-standard) | Not applicable |
| Performance at scale | Good (improving) | Good | Fast (no compile step) |
| satisfies equivalent | Yes | No | No |
Flow's opaque type is actually a cleaner primitive for branded types than TypeScript's workaround. But Flow lost the ecosystem war decisively — it's not a practical choice for new projects unless you're at Meta.
JSDoc types are a legitimate option if your library needs to support plain JavaScript consumers. [Svelte uses JSDoc types](https://github.com/sveltejs/svelte) for this reason. The tradeoff: you lose conditional types, template literals, and most advanced patterns. For complex libraries, TypeScript is the only choice.
For how these patterns integrate with [React Server Components](https://tostupidtooquit.com/blog/react-server-components-mental-model), particularly around serializable props and server/client type boundaries, that post covers the practical integration.
---
A real migration story from Next.js 15.1 with Tailwind v3.4 to Next.js 16 with Tailwind v4. Covers the CSS-first config system, OKLCH color conversion, Turbopack landmines, and every class name that changed.
A three-tier token architecture (primitive, semantic, component) with CSS custom properties, Style Dictionary transforms, Tailwind v4 integration, and dark mode that's a token swap instead of a stylesheet rewrite.
A practical mental model for React Server Components. Covers the two-runtime architecture, serialization boundary rules, composition patterns, streaming with Suspense, caching layers, and common mistakes that break production apps.