TypeScript Generics That Pay Rent: Conditional Types, Mapped Types, and Infer for Production Code
Most TypeScript projects barely scratch the surface of what the type system can enforce at compile time. This post walks through four real-world patterns using conditional types, mapped types, and the infer keyword that eliminate entire categories of runtime bugs.
You have a Product type in your API layer, a ProductRow in your database layer, a ProductResponse in your HTTP layer, and a ProductCardProps in your UI layer. They all describe the same thing, but they are all declared separately. Last week someone added a discountPrice field to the database schema and updated two of the four types. The integration tests passed because they used the database type directly. The UI tests passed because they used the props type directly. The e2e tests failed at 2 AM because the API returned discountPrice and the UI component did not know it existed.
This is the real cost of type drift. It is not a compile error. It is a production incident caused by manually keeping parallel type definitions in sync. The fix is not “write more tests.” The fix is to stop writing duplicate types in the first place.
TypeScript’s advanced generics — conditional types, mapped types, and the infer keyword — let you derive types automatically from your source of truth. If you have never used them outside of library code, you are leaving compile-time guarantees on the table. Here are four patterns that pay their rent in production.
Pattern 1: Extract the inner type from a generic wrapper
Most production APIs wrap responses in a standard envelope. You define it once:
interface ApiResponse<T> {
data: T;
error: string | null;
status: number;
requestId: string;
}
Then every endpoint handler returns an ApiResponse<SomeType>. The problem is that downstream consumers — hooks, state stores, test fixtures — need the inner type without the wrapper. The naive approach is to define it separately:
// src/api/products.ts
export type Product = {
id: string;
name: string;
price: number;
};
// src/hooks/useProducts.ts -- copy-pasted, out of sync within a week
interface ProductFromHook {
id: string;
name: string;
price: number;
// missing: discountPrice
}
Use infer inside a conditional type to extract it automatically:
type UnwrapApiResponse<T> = T extends ApiResponse<infer U> ? U : never;
// Usage -- no manual type needed
type Product = UnwrapApiResponse<ReturnType<typeof getProduct>>;
When getProduct returns ApiResponse<Product>, UnwrapApiResponse extracts Product. When someone adds a field to the product shape in the API layer, every consumer that derives from UnwrapApiResponse picks it up without any changes.
The infer keyword is the most powerful tool TypeScript gives you for this kind of type extraction. It tells the compiler: “match this pattern and capture the inner type.” It works recursively too, which is how libraries like Zod and Prisma derive their types from schemas.
Pattern 2: Transform an object’s keys and values with mapped types
Your application has a configuration object with strict defaults and a partial override system:
interface AppConfig {
host: string;
port: number;
dbUrl: string;
redisUrl: string;
maxRetries: number;
logLevel: 'debug' | 'info' | 'warn' | 'error';
}
The sensible pattern is a deep merge: users provide a partial config, and the system fills in the rest from environment variables or defaults. The naive approach uses Partial<AppConfig>, but that flattens everything. If any nested objects exist, Partial only handles the first level.
A production config often has nested sections, and you need DeepPartial:
type DeepPartial<T> = T extends object
? { [P in keyof T]?: DeepPartial<T[P]> }
: T;
This is a mapped type combined with a conditional type. It maps over every key of T, makes it optional, and recurses into nested objects. Non-object values become T itself (primitive types stay unchanged, arrays stay as-is).
// Usage
function loadConfig(overrides: DeepPartial<AppConfig>): AppConfig {
return merge(defaults, overrides);
}
The same pattern works for marking fields as required, making them readonly, or prefixing all keys:
// Prefix all config keys with an environment name
type PrefixedConfig<T, Prefix extends string> = {
[K in keyof T as `${Prefix}_${string & K}`]: T[K];
};
// Result: { PROD_host: string; PROD_port: number; ... }
The key insight: mapped types let you express transformations that would otherwise require runtime assertions or documentation that nobody reads. The compiler enforces them.
Pattern 3: Filter a discriminated union by a tag value
Event-driven architectures produce this problem constantly. You have a union of event types:
type OrderEvent =
| { type: 'order.created'; orderId: string; customerId: string; items: Array<{ sku: string; qty: number }> }
| { type: 'order.shipped'; orderId: string; trackingNumber: string; carrier: string }
| { type: 'order.cancelled'; orderId: string; reason: string; refundAmount: number }
| { type: 'order.refunded'; orderId: string; refundAmount: number; processedBy: string };
Every handler needs to narrow this union to a specific event type. The standard approach is a switch statement with type guards:
function handleEvent(event: OrderEvent) {
switch (event.type) {
case 'order.created':
// event is narrowed to { type: 'order.created'; orderId: string; ... }
break;
case 'order.shipped':
// event is narrowed to { type: 'order.shipped'; orderId: string; ... }
break;
}
}
This works fine inside a single handler. But when you need to extract a subscriber that only handles one event type, you end up with a helper that casts or re-declares the type:
// The fragile way
function onOrderShipped(event: OrderEvent) {
if (event.type !== 'order.shipped') return;
// Now narrowed, but the return type is still void -- no enforcement
}
A conditional type filters the union at the type level:
type ExtractEvent<T, Type extends string> = T extends { type: Type } ? T : never;
Usage:
type ShippedEvent = ExtractEvent<OrderEvent, 'order.shipped'>;
// Result: { type: 'order.shipped'; orderId: string; trackingNumber: string; carrier: string }
Now your subscriber function has a precise, derived type that stays synchronized with the source union:
function createSubscriber<T extends OrderEvent['type']>(
eventType: T,
handler: (event: ExtractEvent<OrderEvent, T>) => Promise<void>
) {
return { eventType, handler };
}
const shippedSubscriber = createSubscriber('order.shipped', async (event) => {
// event is automatically { type: 'order.shipped'; orderId: string; ... }
await notifyTracking(event.trackingNumber);
});
When someone adds order.delivered to the OrderEvent union, any subscriber that handles order.delivered must match the new shape. No manual type aliases, no casts, no drift.
Pattern 4: Derive function parameter and return types without duplicating signatures
Every codebase has utility functions that wrap a core operation with logging, metrics, retries, or caching:
async function fetchWithRetry<T>(
fn: () => Promise<T>,
options: { maxRetries: number; baseDelay: number }
): Promise<T> {
for (let attempt = 0; attempt <= options.maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt === options.maxRetries) throw err;
await delay(options.baseDelay * Math.pow(2, attempt));
}
}
throw new Error('Unreachable');
}
The problem shows up when you need to build a queue that takes the same function signature but adds rate limiting:
// The fragile way -- redeclare the whole signature
type RateLimitedFn<T> = (
fn: () => Promise<T>,
options: { maxRetries: number; baseDelay: number }
) => Promise<T>;
If fetchWithRetry changes its parameter list, RateLimitedFn becomes a stale copy until someone updates it by hand. Use Parameters and ReturnType (built-in utility types that use infer under the hood):
type FetchWithRetry = typeof fetchWithRetry;
// Derive the parameters automatically
type FetchWithRetryParams = Parameters<FetchWithRetry>;
// [fn: () => Promise<T>, options: { maxRetries: number; baseDelay: number }]
// Derive the return type
type FetchWithRetryReturn = ReturnType<FetchWithRetry>;
// Promise<T>
Now the rate-limited wrapper never drifts:
function withRateLimit<T>(
...args: Parameters<FetchWithRetry>
): ReturnType<FetchWithRetry> {
// args[0] is the function, args[1] is the options -- fully typed
return rateLimiter.enqueue(() => fetchWithRetry(...args));
}
This pattern scales across your entire service boundary. When a core function’s signature evolves, every wrapper, decorator, and mock derived from Parameters or ReturnType stays in sync. No manual updates, no silent drift, no incident at 2 AM from a mismatched type.
When not to use these patterns
Advanced generics add cognitive overhead. Apply them when the benefit (eliminating type drift across a codebase) outweighs the cost (reduced readability for developers who are not familiar with the patterns).
Three rules of thumb:
-
Use them at module boundaries, not inside a single function. The boundary between your API layer and your UI layer is where drift happens. The boundary between a function and its wrapper is where drift happens. Inside a single 20-line function, just use a concrete type.
-
Name the utility types clearly.
UnwrapApiResponseis better thanUART.ExtractEventis better thanFilterUnion. The name should tell the next developer what the type does without reading the implementation. -
Document why you did not write a concrete type. A comment like
// Derived from ApiResponse so product fields stay in syncsaves someone from “fixing” your generic with a concrete type alias that defeats the purpose.
The compile-time guarantee
The four patterns in this post solve one problem: type definitions that duplicate each other and drift apart over time. Conditional types let you express type-level conditions (extends ? :). Mapped types let you transform object types key by key. The infer keyword lets you extract inner types from patterns.
Together, they let you define a type once and derive everything else from it. The compiler becomes your integration test for type consistency. When a field changes in your source-of-truth type, every derived type updates automatically. If a consumer expects a shape that no longer matches, the build fails before the deploy starts.
That is the kind of guarantee that pays rent in every sprint.
A note from Yojji
The discipline of deriving types from a single source of truth instead of maintaining parallel definitions is the kind of architectural rigor that prevents entire categories of production bugs. It is also the kind of engineering practice that Yojji’s teams bring to every codebase they touch, from early-stage products that need a solid foundation to established platforms that have accumulated years of type drift. They specialize in the JavaScript and TypeScript ecosystem, building production systems where the compiler catches the mistakes that tests miss.
Yojji is an international custom software development company founded in 2016, with offices in Europe, the US, and the UK. Their senior engineering teams work across the full product delivery lifecycle, from architecture and design through development, testing, and DevOps, with a focus on building maintainable, type-safe systems that stay reliable as they grow.