TypeScript Conditional Types and Mapped Types: Transform and Derive Types Programmatically
Conditional types and mapped types let you write types that compute — filtering keys, extracting nested types, and deriving new shapes from old ones without duplication.
TypeScript Conditional Types and Mapped Types: Transform and Derive Types Programmatically
Utility types — Partial, Omit, Pick — cover a lot of ground. But they hit a wall when the transformation depends on the values of the type, not just its shape. What if you need to make every method in an interface return void? Filter an object to only keys whose values are strings? Extract the element type of an array, regardless of depth?
Those aren't transforms you can express with the built-in utility set. They require two lower-level features that utility types are themselves built from: conditional types and mapped types.
Once you understand these two primitives, you stop copying types and start computing them.
The Problem: Types That Can't Adapt
Here's a classic pain point. You have an API layer where every function is async, and you want a synchronous mock version for tests:
interface UserService {
getUser(id: string): Promise<User>;
listUsers(filters: UserFilters): Promise<User[]>;
deleteUser(id: string): Promise<void>;
}
// You want this for tests — but you write it by hand:
interface MockUserService {
getUser(id: string): User; // manually unwrapped
listUsers(filters: UserFilters): User[]; // manually unwrapped
deleteUser(id: string): void; // manually unwrapped
}
// When UserService gains a method, MockUserService silently drifts.Or you're working with deeply nested event payloads:
type ClickEvent = { type: 'click'; x: number; y: number };
type KeyEvent = { type: 'key'; key: string; code: string };
type AppEvent = ClickEvent | KeyEvent;
// You want the payload type for a given event type string.
// There's no utility type for "extract the union member where type === T".
// So you write it by hand every time.Conditional types and mapped types solve both problems cleanly.
Conditional Types: Types That Choose
A conditional type has the same shape as a ternary expression, but at the type level:
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
type C = IsString<'hello'>; // true — string literals extend stringThe extends keyword here means "is assignable to" — it's asking whether T fits within string. If yes, you get the left type; if no, the right.
Here's a more practical version: unwrapping a Promise:
type Awaited<T> = T extends Promise<infer U> ? U : T;
type A = Awaited<Promise<string>>; // string
type B = Awaited<string>; // string (not a Promise, pass through)
type C = Awaited<Promise<Promise<number>>>; // Promise<number> (one level)This is exactly how TypeScript's built-in Awaited utility started (TypeScript 4.5 added the recursive version).
The infer Keyword: Capturing Types from Within
infer is the part that makes conditional types genuinely powerful. It lets you declare a placeholder type variable that TypeScript fills in when the condition matches:
// Without infer — you can check, but not capture:
type IsPromise<T> = T extends Promise<unknown> ? true : false;
// With infer — you check AND capture the inner type:
type UnwrapPromise<T> = T extends Promise<infer Inner> ? Inner : T;
type A = UnwrapPromise<Promise<string>>; // string
type B = UnwrapPromise<number>; // numberinfer only works inside the extends clause of a conditional type, and the inferred variable is only available in the true branch.
Here are the patterns you'll reach for constantly:
// Extract function return type:
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
// Extract function parameters as a tuple:
type Parameters<T> = T extends (...args: infer P) => any ? P : never;
// Extract the element type of an array:
type ElementOf<T> = T extends (infer E)[] ? E : never;
type A = ElementOf<string[]>; // string
type B = ElementOf<[1, 2, 3]>; // 1 | 2 | 3 (tuple → union of members)
// Extract the first element of a tuple:
type Head<T extends any[]> = T extends [infer H, ...any[]] ? H : never;
type C = Head<[string, number, boolean]>; // string
// Extract the resolved value from a nested Promise:
type DeepAwaited<T> = T extends Promise<infer U> ? DeepAwaited<U> : T;
type D = DeepAwaited<Promise<Promise<string>>>; // stringNow back to the event payload problem:
type ClickEvent = { type: 'click'; x: number; y: number };
type KeyEvent = { type: 'key'; key: string; code: string };
type AppEvent = ClickEvent | KeyEvent;
// Extract the union member where 'type' matches a string literal:
type EventByType<T extends AppEvent['type']> =
Extract<AppEvent, { type: T }>;
type Click = EventByType<'click'>; // { type: 'click'; x: number; y: number }
type Key = EventByType<'key'>; // { type: 'key'; key: string; code: string }
// Now type-safe event handlers:
function on<T extends AppEvent['type']>(
type: T,
handler: (event: EventByType<T>) => void
) {
// ...
}
on('click', (e) => console.log(e.x, e.y)); // e is ClickEvent
on('key', (e) => console.log(e.key)); // e is KeyEvent
on('click', (e) => console.log(e.key)); // type errorDistributive Conditional Types
When you pass a union to a conditional type, TypeScript distributes the condition over each member:
type ToArray<T> = T extends any ? T[] : never;
type A = ToArray<string | number>;
// Distributes to: ToArray<string> | ToArray<number>
// Result: string[] | number[]
// NOT: (string | number)[]This distributive behavior is the default when T is a bare type parameter. It's what makes Exclude and Extract work:
// Exclude<T, U> = remove U members from T
type Exclude<T, U> = T extends U ? never : T;
type A = Exclude<'a' | 'b' | 'c', 'a' | 'c'>;
// Distributes:
// 'a' extends 'a' | 'c' ? never : 'a' → never
// 'b' extends 'a' | 'c' ? never : 'b' → 'b'
// 'c' extends 'a' | 'c' ? never : 'c' → never
// Final: 'b'To disable distribution — when you want to treat the whole union as a unit — wrap both sides in a tuple:
// This checks if T is *exactly* never (bare conditional distributes, so it never matches):
type IsNever<T> = [T] extends [never] ? true : false;
type A = IsNever<never>; // true
type B = IsNever<string>; // falseMapped Types: Iterating Over Type Keys
Mapped types let you produce a new type by iterating over the keys of an existing one:
// [K in keyof T] iterates over every key in T
type Stringify<T> = {
[K in keyof T]: string;
};
interface User {
id: number;
name: string;
active: boolean;
}
type StringifiedUser = Stringify<User>;
// { id: string; name: string; active: string }This is how Readonly, Partial, and Required are implemented internally:
type Readonly<T> = { readonly [K in keyof T]: T[K] };
type Partial<T> = { [K in keyof T]?: T[K] };
type Required<T> = { [K in keyof T]-?: T[K] }; // -? removes optionalityMapping Modifiers
Two modifiers control mutability and optionality. Prefix with + to add, - to remove:
| Modifier | Effect |
|---|---|
readonly | Makes the property read-only |
-readonly | Removes read-only |
? | Makes the property optional |
-? | Removes optionality (makes required) |
// Deep Readonly — recursively freeze an object:
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
interface Config {
server: { host: string; port: number };
db: { url: string; pool: number };
}
type FrozenConfig = DeepReadonly<Config>;
// {
// readonly server: { readonly host: string; readonly port: number };
// readonly db: { readonly url: string; readonly pool: number };
// }
const config: FrozenConfig = {
server: { host: 'localhost', port: 3000 },
db: { url: 'postgres://...', pool: 10 },
};
config.server.port = 4000; // type error — deeply readonlyKey Remapping with as
TypeScript 4.1 added the as clause, which lets you rename keys during mapping:
// Add a 'get' prefix to every key:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface User {
name: string;
age: number;
}
type UserGetters = Getters<User>;
// { getName: () => string; getAge: () => number }More powerfully, you can filter out keys by mapping them to never:
// Keep only keys whose values are functions:
type FunctionKeys<T> = {
[K in keyof T as T[K] extends Function ? K : never]: T[K];
};
interface Service {
name: string;
version: number;
fetch(url: string): Promise<Response>;
post(url: string, body: unknown): Promise<Response>;
}
type ServiceMethods = FunctionKeys<Service>;
// { fetch: (url: string) => Promise<Response>; post: (url: string, body: unknown) => Promise<Response> }Combining Conditional and Mapped Types
Here's where the pattern pays off at scale. Back to the async service problem from the intro:
// Unwrap Promise return types across all methods of an interface:
type SyncVersion<T> = {
[K in keyof T]: T[K] extends (...args: infer A) => Promise<infer R>
? (...args: A) => R
: T[K];
};
interface UserService {
getUser(id: string): Promise<User>;
listUsers(filters: UserFilters): Promise<User[]>;
deleteUser(id: string): Promise<void>;
readonly apiVersion: string; // non-function property — passed through
}
type MockUserService = SyncVersion<UserService>;
// {
// getUser(id: string): User;
// listUsers(filters: UserFilters): User[];
// deleteUser(id: string): void;
// readonly apiVersion: string;
// }Add a new method to UserService and MockUserService updates automatically. This is the payoff: the mock interface never drifts.
Another real-world pattern — building a strongly-typed form error map from a Zod schema shape:
import { z } from 'zod';
const UserSchema = z.object({
name: z.string(),
email: z.email(),
age: z.number().min(18),
});
type UserInput = z.infer<typeof UserSchema>;
// Derive the error map type — same keys, all string | undefined values:
type FormErrors<T> = {
[K in keyof T]?: string;
};
type UserFormErrors = FormErrors<UserInput>;
// { name?: string; email?: string; age?: string }
// When the schema changes, the error map type updates automatically.
function validateUser(input: unknown): FormErrors<UserInput> {
const result = UserSchema.safeParse(input);
if (result.success) return {};
return Object.fromEntries(
result.error.issues.map((e) => [e.path[0], e.message])
) as FormErrors<UserInput>;
}On Zod 4 the issue list is error.issues. error.errors — the Zod 3 alias you'll find in most
older snippets — is simply undefined there, so error.errors.map(...) throws
TypeError: Cannot read properties of undefined at runtime while type-checking fine.
And a pattern for building type-safe Redux-style action creators:
type ActionMap = {
'user/login': { userId: string; token: string };
'user/logout': { userId: string };
'cart/addItem': { productId: string; qty: number };
};
// Union of discriminated action objects:
type Action = {
[K in keyof ActionMap]: { type: K; payload: ActionMap[K] };
}[keyof ActionMap];
// | { type: 'user/login'; payload: { userId: string; token: string } }
// | { type: 'user/logout'; payload: { userId: string } }
// | { type: 'cart/addItem'; payload: { productId: string; qty: number } }
// Type-safe dispatch:
function dispatch<T extends Action['type']>(
type: T,
payload: Extract<Action, { type: T }>['payload']
) {
// ...
}
dispatch('user/login', { userId: 'u1', token: 'tok' }); // ok
dispatch('cart/addItem', { productId: 'p1', qty: 2 }); // ok
dispatch('user/logout', { userId: 'u1', token: 'x' }); // type error — token not in logout payloadHow the Pieces Fit
Variations and Edge Cases
Recursive Conditional Types
TypeScript supports recursive conditional types (since 4.1) with some caveats — they must be deferred (the recursion can't be in an immediately-evaluated position):
// Recursively flatten nested arrays:
type Flatten<T> = T extends Array<infer U> ? Flatten<U> : T;
type A = Flatten<string[][][]>; // string
type B = Flatten<number[]>; // number
type C = Flatten<boolean>; // boolean (not an array — base case)Template Literal Types with Mapped Types
A powerful combination for generating event handler keys:
type EventNames = 'click' | 'focus' | 'blur';
type EventHandlers = {
[K in EventNames as `on${Capitalize<K>}`]: (event: Event) => void;
};
// { onClick: (event: Event) => void; onFocus: ...; onBlur: ... }Conditional Types for Overload-Like Behavior
// Return type depends on input type, without actual function overloads:
function parse<T extends string | number>(
input: T
): T extends string ? number : string {
if (typeof input === 'string') {
return Number(input) as any;
}
return String(input) as any;
}
const a = parse('42'); // number
const b = parse(42); // stringWhen NOT to Use Conditional and Mapped Types
Don't reach for them when simpler tools work. If Omit<User, 'id'> solves your problem, use it — there's no reason to write a mapped type from scratch.
Avoid deeply nested conditional types in shared APIs. When a third-party consumer hovers over your exported type and sees T extends U ? V extends W ? X : Y : Z, that's a documentation failure. Introduce a named intermediate type.
Don't use infer to re-implement what TypeScript already provides. ReturnType<T>, Parameters<T>, Awaited<T> are built-in. Use them.
Recursive types can cause compiler slowdowns. If you find yourself recursing more than 3–4 levels, benchmark compile times. Deep recursion on large union types can make the TypeScript compiler visibly slow on every save.
Watch out for optional fields in the as filter pattern. T[K] extends string silently excludes string | undefined — which is what optional fields produce. Fix with NonNullable:
// Bug: misses optional string fields
type StringKeysNaive<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
interface Form {
name?: string; // type is string | undefined — doesn't extend string!
email: string; // included
}
type A = StringKeysNaive<Form>; // { email: string } — name is missing!
// Fix: strip undefined before checking:
type StringKeys<T> = {
[K in keyof T as NonNullable<T[K]> extends string ? K : never]: T[K];
};
type B = StringKeys<Form>; // { name?: string; email: string }The Mental Model
Conditional types and mapped types are type-level functions. Conditional types branch on type relationships the way if/else branches on values. Mapped types loop over keys the way Object.keys(...).reduce(...) loops over runtime objects.
The critical difference from runtime code: these computations happen entirely at compile time and produce zero runtime cost. The JavaScript output is identical whether you used SyncVersion<UserService> or wrote the mock interface by hand.
Use conditional types when you need to branch on what a type is. Use mapped types when you need to transform every property of a type. Combine them when you need to transform based on what each property is — which is where the real use lives.
Comments (0)
No comments yet. Be the first to share your thoughts!