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.
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.
Try It Yourself
Build a FormState<T> mapped type that wraps any form's data model. Each field should track its current value, whether it has been touched by the user, and any validation error message:
interface LoginForm {
email: string;
password: string;
rememberMe: boolean;
}
type FieldState<V> = {
value: V;
touched: boolean;
error: string | null;
};
// Each key in T maps to a FieldState wrapping that key's original type
type FormState<T> = {
[K in keyof T]: FieldState<T[K]>;
};
// FormState<LoginForm> produces:
// {
// email: { value: string; touched: boolean; error: string | null };
// password: { value: string; touched: boolean; error: string | null };
// rememberMe: { value: boolean; touched: boolean; error: string | null };
// }
const loginState: FormState<LoginForm> = {
email: { value: "", touched: false, error: null },
password: { value: "", touched: false, error: null },
rememberMe: { value: false, touched: false, error: null },
};
function touchField<T>(state: FormState<T>, field: keyof T): FormState<T> {
return {
...state,
[field]: { ...state[field], touched: true },
};
}
function setError<T>(
state: FormState<T>,
field: keyof T,
error: string | null
): FormState<T> {
return {
...state,
[field]: { ...state[field], error },
};
}
let state = touchField(loginState, "email");
state = setError(state, "email", "Please enter a valid email address");
console.log("email touched:", state.email.touched);
console.log("email error:", state.email.error);
console.log("password touched:", state.password.touched);
Try extending FieldState to add a dirty boolean that tracks whether the current value differs from an initial snapshot, or add an isDirty<T> helper that returns true if any field in a FormState<T> has been modified.
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 — now it's time to apply them in a full-stack framework. Next, you'll learn how TypeScript integrates with Next.js: typed page props, generic API wrappers, server action results, and building a typed data layer.
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.