TL;DR
Learn TypeScript mapped types to transform, filter, and reshape types programmatically. Build your own Partial, Required, and Readonly.
Key concepts
- TypeScript mapped types
- mapped types tutorial
- keyof TypeScript
- type transformations
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.
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.
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
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,
};
// frozen.name = "Bob"; // Error: Cannot assign to 'name' — it is read-only
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.
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 07-generics. What does keyof T actually produce, and why is that what makes [K in keyof T] able to iterate?
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
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.
Remapping Keys with as
TypeScript 4.1 introduced key remapping, letting you rename keys as part of the mapped type using an as clause:
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
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);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. 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.
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; each const consumes the binding above it, so only one order runs top-to-bottom and prints a@b.co.
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]));
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!
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.