Mapped and Conditional Types
Types that compute. The deep end of the language, scoped honestly: worth reading once, worth using rarely.
By the end: Read a mapped or conditional type without flinching, and write a simple one.
Types that compute. This is where TypeScript stops being annotations and becomes a small functional programming language that runs at compile time.
Let me be honest about the scope before we start. This material is worth reading once so that you can understand a library's types, follow an error message that mentions a conditional type, and recognise what a colleague has written. It is worth using rarely. Most application code needs none of it, and a type nobody on your team can read is a liability regardless of how clever it is.
Mapped types
A mapped type walks the keys of one type and produces a new type. The syntax echoes a for...in loop, deliberately.
interface User {
id: number;
name: string;
email?: string;
}
// This is how Readonly is implemented. `[K in keyof T]` iterates the keys.
type MyReadonly<T> = { readonly [K in keyof T]: T[K] };
// And Partial. The `?` is added to each property.
type MyPartial<T> = { [K in keyof T]?: T[K] };
// And Required. The `-?` removes the optional marker.
type MyRequired<T> = { [K in keyof T]-?: T[K] };
// And Pick, which iterates a subset of keys.
type MyPick<T, K extends keyof T> = { [P in K]: T[P] };
const frozen: MyReadonly<User> = { id: 1, name: 'Ada' };
const patch: MyPartial<User> = { name: 'Grace' };
const complete: MyRequired<User> = { id: 2, name: 'Grace', email: 'g@example.com' };
const picked: MyPick<User, 'id' | 'name'> = { id: 3, name: 'Alan' };
console.log(frozen.name, patch.name, complete.email, picked.id);
console.log('these are the real implementations, not approximations');
Output
interface Frozen {
readonly id: number;
readonly tags?: readonly string[];
}
// Strip both modifiers.
type Thaw<T> = { -readonly [K in keyof T]-?: T[K] };
type Mutable = Thaw<Frozen>;
const mutable: Mutable = { id: 1, tags: ['a'] };
mutable.id = 2;
console.log(mutable);
// The value type is an expression, so you can transform it as you go.
type Stringify<T> = { [K in keyof T]: string };
type AllStrings = Stringify<{ a: number; b: boolean }>;
const strings: AllStrings = { a: '1', b: 'true' };
console.log(strings);
// Or wrap it.
type Boxed<T> = { [K in keyof T]: { value: T[K]; touched: boolean } };
const boxed: Boxed<{ name: string }> = { name: { value: 'Ada', touched: false } };
console.log(boxed.name);
Output
Key remapping with as
interface User {
id: number;
name: string;
active: boolean;
}
// `as` after the `in` clause rewrites each key.
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
const getters: Getters<User> = {
getId: () => 1,
getName: () => 'Ada',
getActive: () => true,
};
console.log(getters.getId(), getters.getName(), getters.getActive());
// Mapping a key to `never` drops it, which is how you filter properties.
type OnlyStrings<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
type Narrowed = OnlyStrings<User>;
const narrowed: Narrowed = { name: 'Grace' };
console.log('only the string-valued key survived:', Object.keys(narrowed));
Output
Conditional types
A extends B ? X : Y is a ternary that operates on types. extends here means “is assignable to”, not “inherits from”.
// Reads as: if T is assignable to string, the result is 'text', else 'other'.
type Kind<T> = T extends string ? 'text' : 'other';
const a: Kind<string> = 'text';
const b: Kind<number> = 'other';
console.log(a, b);
// A practical one: unwrap an array, leave anything else alone.
type Element<T> = T extends readonly (infer Item)[] ? Item : T;
const one: Element<string[]> = 'a';
const two: Element<number> = 42;
const three: Element<readonly boolean[]> = true;
console.log(one, two, three);
// Chained conditionals read like an if/else ladder.
type Describe<T> = T extends string
? 'string'
: T extends number
? 'number'
: T extends boolean
? 'boolean'
: T extends unknown[]
? 'array'
: 'object';
const d1: Describe<string> = 'string';
const d2: Describe<number[]> = 'array';
const d3: Describe<{ a: 1 }> = 'object';
console.log(d1, d2, d3);
Output
infer
infer declares a type variable inside a conditional and binds it to whatever matched. It is pattern matching, and it is how ReturnType works.
// If T is a function, capture its return type and use it. Otherwise never.
type MyReturnType<T> = T extends (...args: never[]) => infer R ? R : never;
// Same idea, capturing the parameter tuple instead.
type MyParameters<T> = T extends (...args: infer P) => unknown ? P : never;
// Recursive, so nested promises unwrap all the way down.
type MyAwaited<T> = T extends Promise<infer Inner> ? MyAwaited<Inner> : T;
function example(a: number, b: string): { ok: boolean } {
return { ok: a > 0 && b.length > 0 };
}
const returned: MyReturnType<typeof example> = { ok: true };
const params: MyParameters<typeof example> = [1, 'x'];
const awaited: MyAwaited<Promise<Promise<string>>> = 'unwrapped twice';
console.log(returned, params, awaited);
// infer can capture more than one thing at a time.
type Split<T> = T extends `${infer Head}.${infer Tail}` ? [Head, Tail] : [T];
const split: Split<'user.name'> = ['user', 'name'];
const notSplit: Split<'plain'> = ['plain'];
console.log(split, notSplit);
Output
Distribution, and the surprise it causes
A conditional type applied to a union distributes over the members automatically. That is usually what you want, and occasionally very much not.
// Applied to a union, this runs once per member and unions the results.
type Boxed<T> = T extends unknown ? { value: T } : never;
// So this is { value: string } | { value: number }, not { value: string | number }.
type Either = Boxed<string | number>;
const a: Either = { value: 'text' };
const b: Either = { value: 42 };
console.log(a, b);
// That distribution is how Exclude works.
type MyExclude<T, U> = T extends U ? never : T;
type Quiet = MyExclude<'debug' | 'info' | 'error', 'error'>;
const quiet: Quiet = 'info';
console.log(quiet);
// And Extract, which is the same thing with the branches swapped.
type MyExtract<T, U> = T extends U ? T : never;
type Loud = MyExtract<'debug' | 'info' | 'error', 'error' | 'fatal'>;
const loud: Loud = 'error';
console.log(loud);
Output
// Distributive: asks the question of each member separately.
type IsStringDistributive<T> = T extends string ? true : false;
// Non-distributive: the square brackets stop it, so the question is asked once
// of the entire union.
type IsStringWhole<T> = [T] extends [string] ? true : false;
// The difference is stark on a mixed union.
const distributed: IsStringDistributive<string | number> = true; // boolean, in fact
const whole: IsStringWhole<string | number> = false;
console.log('distributed over members:', distributed);
console.log('asked of the whole union:', whole);
// A common use: detecting `never`, which distribution otherwise swallows.
type IsNever<T> = [T] extends [never] ? true : false;
const isNever: IsNever<never> = true;
const isNotNever: IsNever<string> = false;
console.log('never detected:', isNever, '| string:', isNotNever);
Output
Template literal types
type Method = 'get' | 'post';
type Resource = 'users' | 'orders';
// The cross product, computed. Four members from two unions.
type Route = `/${Resource}`;
type Endpoint = `${Uppercase<Method>} /${Resource}`;
const route: Route = '/users';
const endpoint: Endpoint = 'POST /orders';
console.log(route, endpoint);
// Which means a typo in a route string is a compile error.
const routes: Endpoint[] = ['GET /users', 'POST /users', 'GET /orders', 'POST /orders'];
console.log(routes.length, 'valid endpoints');
// Extracting from a pattern with infer.
type ResourceOf<E> = E extends `${string} /${infer R}` ? R : never;
const extracted: ResourceOf<'GET /users'> = 'users';
console.log('resource extracted from the route:', extracted);
Output
type Method = 'get' | 'post';
type Resource = 'users' | 'orders';
type Endpoint = `${Uppercase<Method>} /${Resource}`;
// Lowercase method, and a resource that does not exist.
const bad: Endpoint = 'get /customers';
console.log(bad);
What the compiler says
error TS2820: Type '"get /customers"' is not assignable to type '"GET /orders" | "GET /users" | "POST /orders" | "POST /users"'. Did you mean '"GET /users"'?
Recursion
// Recurses into nested objects, which plain Readonly does not.
type DeepReadonly<T> = T extends (infer Item)[]
? readonly DeepReadonly<Item>[]
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
interface Config {
name: string;
server: {
host: string;
ports: number[];
};
}
const config: DeepReadonly<Config> = {
name: 'app',
server: { host: 'localhost', ports: [80, 443] },
};
console.log(config.server.host, config.server.ports[0]);
console.log('reading nested values is unaffected');
Output
type DeepReadonly<T> = T extends (infer Item)[]
? readonly DeepReadonly<Item>[]
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
interface Config {
server: { host: string };
}
const config: DeepReadonly<Config> = { server: { host: 'localhost' } };
config.server.host = 'elsewhere';
console.log(config);
What the compiler says
error TS2540: Cannot assign to 'host' because it is a read-only property.
A worked example worth the complexity
To be fair to the material: here is a case where this genuinely earns its place. Typing a path into a nested object, checked, with autocomplete.
// Every valid dotted path through T, as a union of string literals.
type Paths<T> = T extends object
? {
[K in keyof T & string]: T[K] extends object ? K | `${K}.${Paths<T[K]>}` : K;
}[keyof T & string]
: never;
// The type of the value at a given path.
type ValueAt<T, P extends string> = P extends `${infer Head}.${infer Rest}`
? Head extends keyof T
? ValueAt<T[Head], Rest>
: never
: P extends keyof T
? T[P]
: never;
interface Settings {
theme: string;
editor: {
fontSize: number;
minimap: { enabled: boolean };
};
}
function read<T, P extends Paths<T>>(object: T, path: P): ValueAt<T, P> {
let current: unknown = object;
for (const key of path.split('.')) {
current = (current as Record<string, unknown>)[key];
}
return current as ValueAt<T, P>;
}
const settings: Settings = {
theme: 'dark',
editor: { fontSize: 14, minimap: { enabled: true } },
};
// Each of these returns a precisely typed value.
const theme = read(settings, 'theme');
const size = read(settings, 'editor.fontSize');
const enabled = read(settings, 'editor.minimap.enabled');
console.log(theme.toUpperCase());
console.log(size.toFixed(0));
console.log(enabled === true);
Output
type Paths<T> = T extends object
? {
[K in keyof T & string]: T[K] extends object ? K | `${K}.${Paths<T[K]>}` : K;
}[keyof T & string]
: never;
interface Settings {
editor: { fontSize: number };
}
function read<T, P extends Paths<T>>(object: T, path: P): unknown {
return path;
}
console.log(read({ editor: { fontSize: 14 } } as Settings, 'editor.fontSizes'));
What the compiler says
error TS2345: Argument of type '"editor.fontSizes"' is not assignable to parameter of type '"editor" | "editor.fontSize"'.
Reading an error that mentions all this
The practical skill, if you never write a mapped type yourself. Errors involving computed types are long, and they have a reliable structure.
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface User {
id: number;
name: string;
}
// One handler returns the wrong type, and one key is misspelled.
const getters: Getters<User> = {
getId: () => 'not a number',
getName: () => 'Ada',
};
console.log(getters.getName());
What the compiler says
error TS2322: Type 'string' is not assignable to type 'number'.
Your turn
- Implement
Partial,ReadonlyandPickfrom scratch as mapped types. Confirm they behave the same as the built-ins. - Write a mapped type that makes every property of an object nullable —
{ [K in keyof T]: T[K] | null }— and use it. - Write a key-remapping type that prefixes every key with
onand capitalises it, producing an event-handler shape from a data shape. - Write
type Unwrap<T>that removes one layer ofPromise, then make it recursive so it removes all of them. - Write
type IsArray<T>both distributively and non-distributively, apply both tostring | string[], and explain the difference in the results. - Write a template literal type for CSS units —
`${number}px` | `${number}%`— and confirm that'10px'is accepted and'10em'is not. - Finally, take something you wrote for one of the earlier exercises and try to express it with a mapped type. Then decide honestly whether the version you had was better.
Recap
- A mapped type walks the keys of one type to build another.
readonlyand?can be added, and-readonly/-?remove them. - Key remapping with
asrewrites key names; mapping a key toneverdeletes the property, which is how property filtering works. - A conditional type is a ternary over types, where
extendsmeans “is assignable to”.infercaptures whatever matched. - Conditional types distribute over unions automatically. Wrap both sides in tuples —
[T] extends [U]— to stop it, which is the fix when a union produces a surprising result. - Template literal types compute string patterns, so a malformed route or unit string becomes a compile error.
- Recursive types work and have a depth limit, and they are the main cause of slow builds and laggy editors.
- The judgement matters more than the syntax: use this when a shape must track another across many properties, and write it out longhand when it must not.