After TypeScript adoption crosses a certain threshold in a codebase, the basic generic patterns stop being sufficient. You start needing types that reason about other types — types that transform, filter, or infer from their inputs. This guide covers the advanced patterns we rely on at TechYantram for enterprise-scale codebases.
Discriminated Unions: The Foundation of State Machines
Discriminated unions are underused. They model states that are mutually exclusive and exhaustively typeable — which is exactly what most application states are:
type RequestState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error; retryCount: number };
function render<T>(state: RequestState<T>) {
switch (state.status) {
case 'idle': return <EmptyState />;
case 'loading': return <Spinner />;
case 'success': return <DataView data={state.data} />; // data is typed T here
case 'error': return <ErrorView error={state.error} retries={state.retryCount} />;
}
// TypeScript enforces exhaustiveness — no default needed
}
The discriminant field (status) lets TypeScript narrow each branch. No casting, no optional chaining to guard against undefined, no runtime surprises.
Mapped Types: Transform Interfaces Automatically
Mapped types iterate over the keys of an existing type to produce a new one. The built-in utility types (Partial, Required, Readonly) are all mapped types. Writing your own:
// Make every property nullable (not the same as Partial)
type Nullable<T> = { [K in keyof T]: T[K] | null };
// Create a "form state" version of any entity type
type FormState<T> = {
[K in keyof T]: {
value: T[K];
error: string | null;
touched: boolean;
}
};
// Usage
type UserFormState = FormState<User>;
// Inferred as: { name: { value: string; error: string|null; touched: boolean }; ... }
Conditional Types and infer
Conditional types let you branch on type relationships. Combined with infer, you can extract parts of complex types:
// Extract the resolved type of a Promise type Awaited<T> = T extends Promise<infer U> ? U : T; // Extract the first argument type of any function type FirstArg<T extends (...args: any[]) => any> = T extends (first: infer F, ...rest: any[]) => any ? F : never; // Example: auto-type the parameter of an event handler type ClickHandler = (event: MouseEvent) => void; type HandlerEvent = FirstArg<ClickHandler>; // MouseEvent
A practical use: extracting return types from a service layer to avoid re-declaring them in the consuming component:
const userService = {
getUser: async (id: string) => db.user.findUnique({ where: { id } }),
listUsers: async () => db.user.findMany(),
};
type UserService = typeof userService;
type GetUserReturn = Awaited<ReturnType<UserService['getUser']>>;
// Inferred as: User | null — from the Prisma schema, automatically
Template Literal Types
Template literal types compose string unions, useful for type-safe event names, CSS property names, or API endpoint keys:
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type ApiVersion = 'v1' | 'v2';
type Resource = 'users' | 'orders' | 'products';
// Generates all valid endpoint strings
type Endpoint = `/${ApiVersion}/${Resource}`;
// '/v1/users' | '/v1/orders' | '/v1/products' | '/v2/users' | ...
// Typed event bus
type EntityEvent<E extends string, A extends string> = `${E}:${A}`;
type UserEvent = EntityEvent<'user', 'created' | 'updated' | 'deleted'>;
// 'user:created' | 'user:updated' | 'user:deleted'
function on(event: UserEvent, handler: () => void) { /* ... */ }
on('user:created', handler); // ✓
on('user:banned', handler); // ✗ Type error
Satisfies Operator: Infer Narrow, Validate Wide
The satisfies operator (TypeScript 4.9+) validates that a value matches a type while keeping the inferred type narrow:
type PaletteConfig = Record<string, string | [number, number, number]>;
// Without satisfies: type is widened to PaletteConfig, losing specifics
const palette: PaletteConfig = {
primary: '#3b82f6',
accent: [59, 130, 246],
};
palette.primary.toUpperCase(); // Error: string | [number,number,number] has no toUpperCase
// With satisfies: validated as PaletteConfig but type stays narrow
const palette2 = {
primary: '#3b82f6',
accent: [59, 130, 246],
} satisfies PaletteConfig;
palette2.primary.toUpperCase(); // ✓ TypeScript knows primary is a string
palette2.accent[0].toFixed(2); // ✓ TypeScript knows accent is a tuple
Branded Types: Prevent ID Mixups
In a codebase with many entity IDs, passing a userId where an orderId is expected is a runtime bug that TypeScript won't catch if both are string. Branded types fix this:
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };
const toUserId = (id: string): UserId => id as UserId;
const toOrderId = (id: string): OrderId => id as OrderId;
function getOrder(orderId: OrderId) { /* ... */ }
const uid = toUserId('user-123');
const oid = toOrderId('order-456');
getOrder(oid); // ✓
getOrder(uid); // ✗ Type error: UserId is not assignable to OrderId
Brands add zero runtime overhead — they're purely a compile-time fiction — but catch a whole class of logical errors that basic typing misses.
Putting It Together
These patterns compose. A production request handler might use discriminated unions for state, branded types for IDs, conditional types to infer DB return types, and satisfies for configuration objects — all in a way that reads cleanly and refactors safely. The investment in learning these patterns pays dividends as your codebase grows past the point where informal conventions can maintain coherence.