Mapped Types in TypeScript: Producing a New Type from an Existing One
We cover mapped type syntax, how homomorphic mapped types carry readonly and ? modifiers, managing modifiers with + and -, key remapping with as, and filtering properties with never.
Version note: The code in this post targets TypeScript 7.0 and Node 26.
Predict First
We have a config type; all its properties are readonly, and one is optional. We write a type that removes readonly and use it:
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
interface Config {
readonly host: string;
readonly port?: number;
}
const config: Mutable<Config> = { host: "localhost" };
config.host = "example.com";
Does this code compile? If it errors, on which line? Two questions are hidden here: can we create the object without port, meaning is port still optional? And can we assign to host? The answer comes after we see how modifiers travel through mapped types.
for...in over Types
A mapped type is a construct that produces one property for each key in a union. Think of JavaScript's for...in loop: it walks over an object's keys one by one. A mapped type does the same walk at the type level:
type Flags = { [K in "darkMode" | "beta"]: boolean };
const flags: Flags = { darkMode: true, beta: false };
Read the inside of the brackets like a loop header: "for each K in the union "darkMode" | "beta", create a property named K whose type is boolean." The real power shows up when you take the union you walk over from another type. The keyof and indexed access we saw in the Intermediate series meet here:
type Nullable<T> = { [K in keyof T]: T[K] | null };
type Draft = Nullable<{ name: string; age?: number }>;
// { name: string | null; age?: number | null | undefined; }
keyof T gives all the keys, and T[K] the current type of that key. On the right-hand side you apply whatever transformation you want. The Partial, Readonly, Pick and Record we used in the Intermediate series are all defined in lib exactly like this, as mapped types.
Seeing the Result: the Expand Helper
In the first post of the series we assigned null to a type and read the error message to see it. With mapped types that produce objects, that trick alone isn't enough; the compiler writes the alias name, not the expansion:
error TS2322: Type 'null' is not assignable to type 'Mutable<Config>'.
A small helper type that the community uses a lot solves this. It is itself a mapped type; intersecting it with the empty object type ({}) forces the compiler to write out the property list instead of the alias name:
type Expand<T> = { [K in keyof T]: T[K] } & {};
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
interface Config {
readonly host: string;
readonly port?: number;
}
// Error: Type 'null' is not assignable to type '{ host: string; port?: number | undefined; }'.
const reveal: Expand<Mutable<Config>> = null;
The answer to the prediction is in the message: readonly is gone, and ? is still there. So the code compiles without errors; you can create the object without port and change host. In the editor, if you write type X = Mutable<Config> and hover over X, you see the same expansion. We checked the result comments in this post this way with TypeScript 7.0.2.
Homomorphic Mapped Types: Modifiers Carry Over
In Mutable we wrote -readonly but said nothing about ?. Even so, port stayed optional. Here's why: a mapped type of the form [K in keyof T] counts as a homomorphic (structure-preserving) mapped type, and it automatically carries over the readonly and ? markers of every property in T. Write the same keys by hand and that link breaks:
interface Config {
readonly host: string;
readonly port?: number;
}
type Copy<T> = { [K in keyof T]: T[K] };
type Copied = Copy<Config>; // { readonly host: string; readonly port?: number | undefined; }
type ByHand = { [K in "host" | "port"]: Config[K] }; // { host: string; port: number | undefined; }
type HostOnly = Pick<Config, "host">; // { readonly host: string; }
In ByHand the keys are the same, but the compiler doesn't know they came from Config; both readonly and ? are lost, and port becomes a required property that can hold undefined. Pick, on the other hand, keeps the link thanks to its K extends keyof T constraint. The rule in short: if you take the keys from keyof T (or from a parameter constrained to keyof T), the modifiers come along.
readonly and ?: Managing Modifiers with + and -
Since TypeScript 2.8 we can put + or - in front of modifiers. + adds (it's the default even if you don't write it), - removes:
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Concrete<T> = { [K in keyof T]-?: T[K] };
type Frozen<T> = { +readonly [K in keyof T]+?: T[K] };
type C1 = Concrete<{ a?: string | undefined; b?: number }>; // { a: string; b: number; }
type C2 = Frozen<{ a: string }>; // { readonly a?: string | undefined; }
Concrete is the same as lib's Required. There's a subtlety: -? doesn't just remove the question mark, it also strips undefined from the property type. Even if a?: string | undefined includes undefined explicitly, the result is a: string. That's the type-level way of saying "this field is definitely filled now".
Key Remapping with as
Since TypeScript 4.1 a mapped type can have an as clause. The type you write after as becomes the property's new name:
interface User {
id: number;
name: string;
password: string;
save(): void;
}
type PublicUser = { [K in keyof User as Exclude<K, "password">]: User[K] };
// { id: number; name: string; save: () => void; }
We filtered the key with Exclude, but on the value side we can still read User[K] with the original K. Omit does a similar job; the difference with as is that you can transform the name however you like. We'll turn keys into new names like getName and onClick in the next post, together with template literal types.
as opens one more door: you can now walk over any union, not just keys. Defining a separate handler for each member of a discriminated union is a nice example:
type Shape = { kind: "circle"; radius: number } | { kind: "square"; size: number };
type ShapeHandlers = { [S in Shape as S["kind"]]: (shape: S) => number };
const area: ShapeHandlers = {
circle: (shape) => Math.PI * shape.radius ** 2,
square: (shape) => shape.size ** 2,
};
console.log(area.square({ kind: "square", size: 3 })); // 9
S here isn't a key; it's a member of the union, which is an object type. We use its kind as the key and S itself in the value. As a result, the circle handler only accepts circles and the square handler only accepts squares; we didn't write any narrowing for shape.radius.
Filtering: as never
In the previous two posts we saw that never drops out of a union without a trace. In a mapped type, too, if as produces never for a key, that property is never created. That lets you filter by value instead of by key:
interface Product {
id: number;
title: string;
note?: string;
price: number;
}
type PickByValue<T, V> = { [K in keyof T as T[K] extends V ? K : never]: T[K] };
type NumberFields = PickByValue<Product, number>; // { id: number; price: number; }
A conditional type runs for each property: if its value is assignable to number, the key stays; if not, it becomes never and drops out. note is eliminated too, since its type string | undefined isn't assignable to number. The same pattern works for dropping function properties and keeping only data fields: T[K] extends Function ? never : K.
Mapped Types over Arrays and Tuples
Homomorphic mapped types behave specially on arrays and tuples: the result is still an array or a tuple, and the length is preserved:
type Stringify<T> = { [K in keyof T]: string };
type S1 = Stringify<[number, boolean]>; // [string, string]
type S2 = Stringify<number[]>; // string[]
const pair: Stringify<[number, boolean]> = ["1", "true"];
The compiler doesn't transform array members like length or push here; it only touches the elements. This behavior ends the moment you add as. { [K in keyof T as K]: T[K] } looks like it changes nothing, but applied to a tuple the result is no longer a tuple; it's an ordinary object type that carries length, push and every other array method as properties.
Common Pitfalls
A pattern that was common before as arrived still lives in a lot of code: collecting, as a union, the keys whose value has a certain type. It has a sneaky hole with optional properties:
interface Product {
id: number;
title: string;
note?: string;
price: number;
}
type KeysOfType<T, V> = { [K in keyof T]: T[K] extends V ? K : never }[keyof T];
const key: KeysOfType<Product, string> = undefined; // compiles!
Because the homomorphic mapped type carries the ? of note, that property's type becomes never | undefined, and the final [keyof T] access adds an undefined to the union. The fix is to use -? in the intermediate type: { [K in keyof T]-?: … }[keyof T]. Then the result is exactly "title", and assigning undefined gives TS2322.
The second is modifiers vanishing silently. If you take the keys from a hand-written union instead of keyof T, readonly and ? don't carry over. If you catch yourself wondering "why is this field required now?", first check whether the mapped type is homomorphic.
The third is that readonly is shallow:
interface Settings {
readonly theme: { color: string };
}
const settings: Readonly<Settings> = { theme: { color: "dark" } };
settings.theme.color = "light"; // compiles: the inner object is not readonly
A mapped type only goes one level deep. To freeze nested objects as well, the mapped type has to call itself; we'll write that DeepReadonly in the post on recursive types.
A Small Experiment
★☆☆ Predict, then verify with Expand: given type Flags<T> = { [K in keyof T]: boolean };, which type is Flags<{ readonly a?: string }>? Do readonly and ? stay?
★★☆ Write the opposite of PickByValue, OmitByValue<T, V>. OmitByValue<Product, number> should be { title: string; note?: string | undefined; }.
★★★ Apply this post's Mutable to the tuple readonly [1, 2]. Does the result stay a tuple, or does it turn into an object? Why?
With mapped types we walked over keys, filtered them and changed their modifiers. In the next post we'll transform the names of the keys as well: template literal types, intrinsics like Uppercase and Capitalize, and mapped types that derive getName from name and onClick from click.