Mapped Types
TypeScript's built-in utility types like Partial<T>, Required<T>, and Readonly<T> feel almost magical — they transform entire type structures with a single generic. Mapped types are the mechanism behind all of them. Once you understand mapped types, you can write your own type transformations that adapt to any shape of data.
A mapped type iterates over the keys of another type and produces a new type based on rules you define. Think of it as a map() function — but for types instead of arrays.
What You'll Learn
By the end of this lesson you will write your own Partial, Readonly and Required from scratch, and then a filter that drops properties by their value type — and you will be able to say, for any of them, exactly which lane does the work. That last part is what makes this lesson worth its place: every transformation here happens entirely at compile time and leaves no trace in the running program, so the payoff is not a behaviour you can print but an error the compiler raises before you ship.
This is the first of the optional extension lessons — the core arc ended at the capstone, and nothing here is required to finish the tracker. It is here for when a real project makes you copy a type and change one thing about every property. You arrive able to read keyof T and the indexed access T[K] (Generics) and to write interfaces and unions (Interfaces and Types); what is new is using those two tools to iterate a type's keys and rebuild it.
The Basic Syntax
The core syntax of a mapped type uses in keyof:
interface Product {
id: number;
name: string;
price: number;
inStock: boolean;
}
// Create a version where every field is a string
type Stringified<T> = {
[K in keyof T]: string;
};
type StringProduct = Stringified<Product>;
// {
// id: string;
// name: string;
// price: string;
// inStock: string;
// }
const display: StringProduct = {
id: "42",
name: "Widget",
price: "$9.99",
inStock: "Yes",
};
console.log(display);
[K in keyof T] says: "for each key K that exists in T, create a property named K". The value type on the right side determines what each property holds. Right now we're replacing every value with string, which isn't very useful — but it illustrates the iteration.
Recall
Without scrolling up: a mapped type is written { [K in keyof T]: ... }. You first met keyof and the generic constraint K extends keyof T back in the Generics lesson. What does keyof T actually produce, and why is that what makes [K in keyof T] able to iterate?
Preserving the Original Types
The real power comes from using T[K] — an indexed access type — to look up each property's original type:
interface User {
id: number;
name: string;
email: string;
isAdmin: boolean;
}
// Make every property optional — this is exactly how Partial<T> works internally.
// The right-hand side is T[K], not a fixed type: writing `unknown` here would
// also compile, and would throw away every value type in the process.
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
// Make every property readonly — this is how Readonly<T> works
type MyReadonly<T> = {
readonly [K in keyof T]: T[K];
};
type PartialUser = MyPartial<User>;
type ReadonlyUser = MyReadonly<User>;
const draft: PartialUser = { name: "Alice" }; // all fields are optional
const frozen: ReadonlyUser = {
id: 1,
name: "Alice",
email: "alice@example.com",
isAdmin: false,
};
console.log("Draft:", draft);
console.log("Frozen:", frozen);
T[K] threads the original value type through unchanged. Adding ? makes every property optional. Adding readonly makes every property immutable. The keys and value types are preserved — only the modifiers change.
What readonly is worth, and to whom
It is worth being exact about what MyReadonly bought, because it is the first mapped type here whose effect you might expect to see at run time. Assign to a field of frozen and the compiler stops you. This fence is marked no-run because that refusal is its entire content — the runner would execute the assignment without complaint, which is the opposite of the point:
interface User { id: number; name: string; email: string; isAdmin: boolean; }
type MyReadonly<T> = {
readonly [K in keyof T]: T[K];
};
const frozen: MyReadonly<User> = {
id: 1,
name: "Alice",
email: "alice@example.com",
isAdmin: false,
};
frozen.name = "Bob";
Under tsc --strict that last line fails with:
error TS2540: Cannot assign to 'name' because it is a read-only property.
Now watch the same assignment on the runner, which is what the Run button does to this lesson's every claim:
interface User {
id: number;
name: string;
email: string;
isAdmin: boolean;
}
type MyReadonly<T> = {
readonly [K in keyof T]: T[K];
};
const frozen: MyReadonly<User> = {
id: 1,
name: "Alice",
email: "alice@example.com",
isAdmin: false,
};
frozen.name = "Bob";
console.log("the runner reassigned it:", frozen.name);
It prints the runner reassigned it: Bob. The readonly modifier is erased along with the rest of the type, so nothing at run time defends the property — Object.freeze is the runtime tool, and it is a different tool with a different cost. That is the honest scope of every transformation in this lesson: they are compile-time constraints, they are enforced by the compiler alone, and their whole value is that the assignment above never reaches a running program in a codebase that typechecks.
Adding and Removing Modifiers
You can not only add modifiers, but also remove them using the - prefix:
interface Config {
readonly host: string;
readonly port: number;
readonly debug?: boolean;
}
// Remove readonly — makes all properties mutable
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
// Remove optional — makes all properties required.
// `-?` is what does the work. Omitting it entirely would preserve whatever
// optionality T already had rather than stripping it, which is why removing a
// modifier needs its own syntax instead of just leaving the modifier out.
type MyRequired<T> = {
[K in keyof T]-?: T[K];
};
type MutableConfig = Mutable<Config>;
type RequiredConfig = MyRequired<Config>;
// MutableConfig allows reassignment
const cfg: MutableConfig = { host: "localhost", port: 3000 };
cfg.host = "example.com"; // Now allowed!
// RequiredConfig forces all fields to be present
const full: RequiredConfig = {
host: "example.com",
port: 443,
debug: true, // Can no longer be omitted
};
console.log("Mutable config:", cfg);
console.log("Required config:", full);
The - prefix removes a modifier. -readonly strips read-only enforcement, -? strips optionality. The TypeScript standard library uses both of these patterns to implement Required<T> and to build mutable versions of types.
A Detour: Conditional Types and infer
The next two sections reach for one piece of machinery you have not met yet, so here it is on its own before it turns up inside something larger. Both are covered in full in Advanced Patterns, the next lesson; what follows is the minimum needed to read the fences below.
A conditional type is an if at the type level, written T extends U ? A : B. The compiler checks whether T is assignable to U and picks a branch — per type, at compile time, with nothing left at run time. You have already seen one without being told: the standard library defines Exclude<T, U> as T extends U ? never : T (Utility Types).
infer goes with it. Inside the extends clause you can write infer Name to say "match any type here, and call it Name" — the compiler works out what it must have been, and the true branch can use it:
// A conditional type: T extends U ? A : B, chosen per-type at compile time.
type Kind<T> = T extends string ? "text" : "other";
// infer names a piece of the matched shape so the true branch can use it.
type ElementOf<T> = T extends Array<infer Item> ? Item : never;
type A = Kind<string>; // "text"
type B = Kind<number>; // "other"
type C = ElementOf<number[]>; // number
type D = ElementOf<string>; // never
// The types above are erased. These runtime values just prove the annotations hold.
const a: A = "text";
const c: C = 42;
console.log(a, c);
That fence runs and prints text 42, but the printing is beside the point — it is there only to show the annotations are accepted. The real evidence is what the compiler rejects. Annotate against the other branch and tsc --strict says so:
type Kind<T> = T extends string ? "text" : "other";
type ElementOf<T> = T extends Array<infer Item> ? Item : never;
const wrong: Kind<number> = "text";
const alsoWrong: ElementOf<string> = 1;
error TS2322: Type '"text"' is not assignable to type '"other"'.
error TS2322: Type '1' is not assignable to type 'never'.
Read those two errors as the results they are: Kind<number> really did resolve to "other", and ElementOf<string> really did resolve to never, because string is not an array so the false branch was taken. This fence is marked no-run because its whole point is those diagnostics — the runner strips the annotations and would execute both lines happily, printing nothing and contradicting the lesson.
One more piece and the next fence is readable: a conditional type may call itself. Capitalize<S> is a built-in that upper-cases the first character of a string literal type, and a type that peels one piece off a string and recurses on the rest can walk the whole string at compile time. That is exactly what CamelCase below does.
Remapping Keys with as
TypeScript 4.1 introduced key remapping, letting you rename keys as part of the mapped type using an as clause. It uses the conditional-type machinery from the detour above — the template literal pattern ${infer Head}_${infer Tail} matches a snake_case key and names both halves:
interface ApiResponse {
user_id: number;
first_name: string;
last_name: string;
created_at: string;
}
// Recursively convert snake_case to camelCase using template literal types
type CamelCase<S extends string> =
S extends `${infer Head}_${infer Tail}`
? `${Head}${Capitalize<CamelCase<Tail>>}`
: S;
type CamelCaseKeys<T> = {
[K in keyof T as CamelCase<string & K>]: T[K];
};
type CamelResponse = CamelCaseKeys<ApiResponse>;
// {
// userId: number;
// firstName: string;
// lastName: string;
// createdAt: string;
// }
const response: CamelResponse = {
userId: 1,
firstName: "Alice",
lastName: "Smith",
createdAt: "2024-01-01",
};
console.log(response);
The as clause lets you transform the key using any type-level operation — template literals, Capitalize, Lowercase, Uncapitalize, or a conditional expression. This is how you build adapters between different naming conventions at the type level.
Filtering Keys with never
When you remap a key to never, TypeScript drops it from the resulting type entirely. This lets you filter properties based on their value types:
interface Employee {
id: number;
name: string;
salary: number;
department: string;
isActive: boolean;
}
// Keep only properties whose value type extends ValueType.
// The false branch is `never` specifically: it is the only key type the
// compiler drops rather than keeps. Returning `undefined` or an empty string
// there would produce a real key named undefined or "" instead of no key.
type PickByValue<T, ValueType> = {
[K in keyof T as T[K] extends ValueType ? K : never]: T[K];
};
type StringFields = PickByValue<Employee, string>;
// { name: string; department: string }
type NumberFields = PickByValue<Employee, number>;
// { id: number; salary: number }
const textOnly: StringFields = {
name: "Bob",
department: "Engineering",
};
const numsOnly: NumberFields = {
id: 7,
salary: 95000,
};
console.log("String fields:", textOnly);
console.log("Number fields:", numsOnly);
Remapping a key to never is the idiomatic way to filter mapped types. The conditional T[K] extends ValueType ? K : never evaluates per-key at the type level — keys that produce never simply disappear from the output.
Predict
NumbersOnly remaps every non-number key of Row to never, so it should be { id: number; score: number } — name and active are filtered out. The literal below tries to include name. This exact code is checked with tsc --strict AND run by the tsx runner. Predict BOTH: what does tsc report, and what does the runner print?
interface Row {
id: number;
name: string;
active: boolean;
score: number;
}
type NumbersOnly = {
[K in keyof Row as Row[K] extends number ? K : never]: Row[K];
};
const n: NumbersOnly = { id: 1, score: 99, name: "x" };
console.log(n);When the Optional Modifier Bites
MyPartial adds one character — a ? — to every property, and that character changes what reading the property means. The compiler now knows each field may be absent. The runner has never known anything else. Here is what happens when only one of them is paying attention:
Debug
This applies a patch of overrides over a set of defaults and reports the result. Every type is honest, tsc --strict is clean, and it still fails its own check with a summary line that reads wrong. Commit a hypothesis about which object the summary is reading its retries from, then fix it so the line reports the merged value.
import assert from "node:assert";
interface Settings {
host: string;
port: number;
retries: number;
}
// MyPartial is the mapped type from earlier: every property becomes optional.
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
const defaults: Settings = { host: "localhost", port: 3000, retries: 3 };
// Apply a patch over the defaults and report the result.
function applyPatch(base: Settings, patch: MyPartial<Settings>): string {
const merged = { ...base, ...patch };
return `${merged.host}:${merged.port} (retries ${patch.retries})`;
}
const line = applyPatch(defaults, { port: 8080 });
assert.ok(
!line.includes("undefined"),
"the summary should report the merged retries, got: " + line,
);
console.log(line);Expected output:
localhost:8080 (retries 3)
Arrange the code
Reassemble a program that turns a plain data model into a form state at the value level — the runtime shape a mapped type describes. It reads the model's keys, wraps each key's value in a field object, assembles them into a record, and logs one field's value. The lines are shuffled. Put them back in an order that runs — then answer what the puzzle is actually testing: which line could not have moved, and what does the runner do if it does?
const model = { email: "a@b.co", agree: true };const fields = keys.map((k) => ({ key: k, value: (model as Record<string, unknown>)[k], touched: false }));console.log(form.email.value);const keys = Object.keys(model);const form = Object.fromEntries(fields.map((f) => [f.key, f]));
Try It Yourself
You've seen every piece of the mapped-type toolkit: iterating keys with [K in keyof T], threading value types with T[K], and adding or removing modifiers. Now build the runtime layer that a mapped type describes. The FormState type defined in the fence below wraps each field of a data model in a value / touched / error record; the three helpers transform that state immutably — the value-level echo of what a mapped type does at the type level, using the immutable-copy technique from Preserving the Original Types. touch marks one named field as touched, setError records an error message against one named field, and isDirty reports whether any field in the state has been touched — false for a pristine form, true as soon as one field has been. Each is stubbed with only its signature. Run it as-is to see the first failure, implement that, then work down until it prints All checks passed.
Build
Finish the build. Three helpers over a FormState — a record mapping each field name to a value/touched/error object, defined in the fence below — are stubbed with only their signatures; the checks below them fail until each returns the right value. The spec is in the prose above and the section heading Preserving the Original Types (for the immutable-copy technique). Every helper must return a NEW state and never mutate the one passed in. Checks run top to bottom, so the first failure is TODO 1 — implement it first, then work down.
import assert from "node:assert";
// Mapped types describe a transform at the TYPE level; the helpers below carry out
// the matching transform on real VALUES. Domain locked — do not edit.
interface Field {
value: string;
touched: boolean;
error: string | null;
}
type FormState = Record<string, Field>;
const login: FormState = {
email: { value: "", touched: false, error: null },
password: { value: "", touched: false, error: null },
};
// TODO 1
function touch(state: FormState, key: string): FormState {
return state;
}
// TODO 2
function setError(state: FormState, key: string, error: string): FormState {
return state;
}
// TODO 3
function isDirty(state: FormState): boolean {
return false;
}
// --- Build checks: these must all pass. Do not edit below this line. ---
const t1 = touch(login, "email");
assert.strictEqual(t1.email.touched, true, "TODO 1: touch should mark the field touched");
assert.strictEqual(t1.password.touched, false, "TODO 1: touch must leave other fields alone");
assert.strictEqual(login.email.touched, false, "TODO 1: touch must not mutate the input state");
const t2 = setError(t1, "email", "Required");
assert.strictEqual(t2.email.error, "Required", "TODO 2: setError should set the field's error");
assert.strictEqual(t2.email.touched, true, "TODO 2: setError must preserve the rest of the field");
assert.strictEqual(t1.email.error, null, "TODO 2: setError must not mutate the input state");
assert.strictEqual(isDirty(login), false, "TODO 3: a pristine form is not dirty");
assert.strictEqual(isDirty(t2), true, "TODO 3: a form with a touched field is dirty");
console.log("All checks passed.");
console.log("email touched:", t2.email.touched);
console.log("email error:", t2.email.error);
console.log("dirty:", isDirty(t2));Expected output:
All checks passed. email touched: true email error: Required dirty: true
Once it passes, try two variations and predict each before running:
- Break the immutable copy in
setError. Change its body toreturn { ...state, [key]: { value: state[key].value, touched: false, error } };— rebuilding the field from scratch instead of spreading it. Predict which check fails first before running. Theerroris still set, but you dropped the field's existingtouched, resetting it tofalse. TODO 2's second check fails first withAssertionError: TODO 2: setError must preserve the rest of the field—false !== true— becauset1.emailwas already touched. Spreading...state[key]is exactly what carries the untouched fields across; rebuilding by hand loses them. - Swap
someforeveryinisDirty. Change the body toreturn Object.values(state).every((f) => f.touched);. Predict which check fails first before running. NowisDirtyonly returnstruewhen all fields are touched. In the test onlyemailis touched (notpassword), soisDirty(t2)returnsfalse, and TODO 3's second check fails withAssertionError: TODO 3: a form with a touched field is dirty—false !== true. "Any field touched" issome, notevery; the quantifier is the whole spec.
Transfer
A mapped type derives one type from another by walking its fields and changing something uniformly - Partial makes each optional, Readonly makes each immutable. Rust reaches the same goal with a derive macro that reads a struct's fields and generates code from them, and Python with a decorator that inspects a class's annotations at import time. All three produce something the author never wrote by hand. Which statement names what genuinely transfers?
Key Takeaways
- Mapped types use
{ [K in keyof T]: ... }to iterate over the keys of an existing type and produce a new type with transformed structure T[K](indexed access) preserves each property's original value type as you change the surrounding modifiers- Modifiers can be added (
readonly,?) or removed (-readonly,-?) in the mapped type syntax — all of TypeScript's core utility types are built this way - The
asclause enables key remapping using template literals,Capitalize,Lowercase, and conditional type expressions - Remapping a key to
neverfilters that property out of the resulting type entirely — this is the standard pattern for value-type-based filtering - All built-in utility types —
Partial,Required,Readonly,Record,Pick— are implemented using mapped types; understanding the pattern demystifies them and lets you write your own
Pro Tip: When you find yourself copy-pasting a type and changing one thing about every property — making them all optional, all nullable, all readonly — that's the signal to reach for a mapped type. A well-named generic mapped type applied once is far easier to maintain than five manually-synced variant types scattered across your codebase. Name your mapped types after what they represent (
FormState<T>,Patchable<T>,Serialized<T>), not how they work (WithOptionalFields<T>).
Next Steps
You've mastered type-level transformations — mapped types are the machinery behind the utility types you already use. Next, you'll combine them with the rest of the advanced toolkit: discriminated unions, template literal types, conditional types with infer, and branded types.
Ready to continue? Head to Advanced Patterns!
Next lesson
Advanced Patterns
Master advanced TypeScript patterns: discriminated unions, template literal types, conditional types, infer, and branded types.
28 min