TypeScript mapped types let you build a new object type by looping over the keys of an existing one, for example "the same shape as Course, but every property is readonly". They depend on a few other type operators: unions, keyof and indexed access. Once you know how these combine, you can derive types from each other instead of copying and maintaining near-identical definitions by hand.
This guide covers unions and intersections, discriminated unions, keyof, indexed access, index signatures, mapped types, key remapping and template literal types. Examples compile under TypeScript 5.x with strict.
Union types: one of several types
A union type A | B means a value can be any one of the listed types. You can only use members that exist on every type in the union until you narrow it:
type Id = string | number;
function formatId(id: Id): string {
// id.toUpperCase() here would fail: it doesn't exist on number
return typeof id === "number"
? id.toString().padStart(6, "0")
: id.toUpperCase();
}Unions of literal types are the most common form, such as "GET" | "POST" or "sm" | "md" | "lg". If literal types are new to you, start with TypeScript basic types.
Intersection types: all of several types
An intersection A & B means a value has every property of both types. It's how you compose object types from smaller pieces:
type Timestamps = { createdAt: Date; updatedAt: Date };
type Product = { id: string; name: string; price: number };
type ProductRecord = Product & Timestamps;
// needs id, name, price, createdAt and updatedAtThe names can be confusing at first. A union of object types gives a value that has fewer guaranteed properties, because only shared ones are safe. An intersection gives a value that has more, because it must satisfy both.
Watch for conflicting properties. In { id: string } & { id: number }, id has to be both a string and a number at once, so its type becomes never and no value can satisfy it. TypeScript doesn't report the conflict where you write the intersection. You only find out when an assignment fails.
Discriminated unions: modeling states safely
A discriminated union is a union of object types that share one literal property, the discriminant. Checking that property narrows the whole object. This is the cleanest way to model data that can be in one of several states:
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string };
function render(state: RequestState<User[]>): string {
switch (state.status) {
case "idle":
return "Nothing loaded yet";
case "loading":
return "Loading...";
case "success":
return `${state.data.length} users`; // data exists only here
case "error":
return `Failed: ${state.error}`;
}
}Compare this with the common alternative, { loading: boolean; data?: T; error?: string }. That shape allows impossible combinations, such as loading: true with an error and data at the same time, and every consumer has to check optional fields. The discriminated union makes those states impossible to represent. Accessing state.data without checking status first is a compile error.
Because each case returns and the union is fully covered, TypeScript knows the function always returns a string. Add a fifth state and it will report that the function lacks an ending return statement.
keyof and indexed access types
keyof T produces a union of the property names of T. An indexed access type T[K] looks up the type of a property, just as obj[key] looks up a value:
interface Course {
title: string;
credit: number;
}
type CourseKey = keyof Course; // "title" | "credit"
type Credit = Course["credit"]; // number
type CourseValue = Course[keyof Course]; // string | numberTogether they let you write functions that stay type-safe for any key:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const course: Course = { title: "TypeScript", credit: 3 };
const title = getProperty(course, "title"); // string
getProperty(course, "teacher");
// Error: '"teacher"' is not assignable to parameter of type 'keyof Course'The K extends keyof T constraint is a generic constraint. TypeScript generics covers constraints and inference in detail.
Indexed access also works with number to get the element type of an array or tuple: (typeof items)[number].
Index signatures (indexable types)
When an object is used as a dictionary with keys you don't know in advance, describe it with an index signature:
interface Scores {
[studentId: string]: number;
}
const scores: Scores = { s001: 88, s002: 92 };
const s3 = scores["s003"]; // typed as number, but undefined at runtime
type ScoreKeys = keyof Scores; // string | numberTwo things to know. First, keyof a string index signature is string | number, because JavaScript converts numeric keys to strings. Second, TypeScript assumes every lookup succeeds. Enable noUncheckedIndexedAccess in tsconfig.json to get number | undefined from lookups like scores["s003"], which forces you to handle missing keys. For dictionaries, Map<string, number> is often a better fit anyway.
Mapped types: transforming every property
A mapped type iterates over a union of keys, usually keyof T, and produces a property for each one. Think of it as a for...in loop at the type level:
type CourseReadonly = {
readonly [K in keyof Course]: Course[K];
};
// { readonly title: string; readonly credit: number }K in keyof Course visits "title" and then "credit", and Course[K] keeps each property's original type.
Adding and removing modifiers
Mapped types can add or remove readonly and ?. A + prefix adds a modifier, which is the default, and - removes it:
type CourseOptional = {
[K in keyof Course]?: Course[K];
};
type CourseRequired = {
[K in keyof CourseOptional]-?: CourseOptional[K]; // remove optional
};
type CourseMutable = {
-readonly [K in keyof CourseReadonly]: CourseReadonly[K]; // remove readonly
};
type CourseWithSemester = CourseReadonly & { semester: string };A common mistake is writing Course instead of Course[K] as the property type, which makes every property the whole Course object. Use the indexed access T[K] to keep each property's own type.
Generic mapped types
Mapped types become reusable when you make them generic:
type Nullable<T> = { [K in keyof T]: T[K] | null };
type FormErrors<T> = { [K in keyof T]?: string };
const errors: FormErrors<Course> = { credit: "Must be a positive number" };TypeScript ships many of these as built-in utility types: Partial<T>, Required<T>, Readonly<T>, Record<K, V> and Pick<T, K> are all mapped types. See the TypeScript utility types guide for the full set. Mapped types written as [K in keyof T] are called homomorphic, and they preserve the original readonly and ? modifiers unless you change them.
Key remapping with as
Since TypeScript 4.1, a mapped type can rename keys with an as clause. Combined with template literal types, this generates new property names:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type CourseGetters = Getters<Course>;
// { getTitle: () => string; getCredit: () => number }string & K is needed because keyof T can include number and symbol keys, and Capitalize only accepts strings.
Remapping a key to never removes it, which lets you filter properties by their type:
type OmitMethods<T> = {
[K in keyof T as T[K] extends (...args: never[]) => unknown ? never : K]: T[K];
};
class Cart {
items: string[] = [];
total = 0;
add(item: string) {
this.items.push(item);
}
}
type CartData = OmitMethods<Cart>; // { items: string[]; total: number }The T[K] extends ... ? never : K part is a conditional type, explained further in the generics article.
Template literal types
Template literal types use the same backtick syntax as JavaScript template strings, but produce types. With a union inside, they produce every combination:
type Method = "GET" | "POST";
type Endpoint = `/api/${string}`;
type RouteKey = `${Method} ${Endpoint}`;
const ok: RouteKey = "GET /api/users"; // OK
const bad: RouteKey = "PATCH /api/users"; // Error
type Size = "sm" | "md" | "lg";
type Color = "primary" | "neutral";
type ButtonClass = `btn-${Color}-${Size}`; // 6 combinationsTypeScript provides four intrinsic helpers for string types: Uppercase, Lowercase, Capitalize and Uncapitalize. For example, `on${Capitalize<"click" | "focus">}` gives "onClick" | "onFocus".
Keep the unions small: three unions of 20 members each already produce 8,000 combinations.
Putting it together: a typed event map
Here is a small event system where one type definition drives the event names, payload types and handler names:
type Events = {
userCreated: { id: string; email: string };
orderPaid: { orderId: string; amount: number };
};
const handlers: { [K in keyof Events]: Array<(payload: Events[K]) => void> } = {
userCreated: [],
orderPaid: [],
};
function on<K extends keyof Events>(name: K, handler: (payload: Events[K]) => void): void {
handlers[name].push(handler);
}
function emit<K extends keyof Events>(name: K, payload: Events[K]): void {
for (const handler of handlers[name]) handler(payload);
}
on("orderPaid", (p) => console.log(p.amount.toFixed(2))); // p is typed
emit("orderPaid", { orderId: "o-1", amount: 250 }); // OK
emit("orderPaid", { orderId: "o-1" }); // Error: amount is missing
emit("orderShipped", {}); // Error: unknown event
// Props for a component that accepts optional callbacks:
type Listeners<E> = {
[K in keyof E as `on${Capitalize<string & K>}`]?: (payload: E[K]) => void;
};
type AppListeners = Listeners<Events>; // { onUserCreated?: ...; onOrderPaid?: ... }Add a new event to Events and the handler registry, emit, on and the listener props all update, and the compiler points to every place that needs to handle it.
FAQ
What is the difference between a union and an intersection type?
A union A | B is either type, so you can only use what both have in common until you narrow it. An intersection A & B is both types at once, so it has all the properties of each.
What is the difference between a mapped type and Record?
Record<K, V> is itself a mapped type: { [P in K]: V }. It gives every key the same value type. A custom mapped type over keyof T can keep or transform each property's own type.
What does -? mean in TypeScript?
It removes the optional modifier in a mapped type, making every property required. -readonly removes readonly in the same way. The built-in Required<T> uses -?.
Why does keyof return string | number?
When the type has a string index signature, JavaScript converts numeric keys to strings, so both kinds of key are valid. Use Extract<keyof T, string> or string & keyof T when you need only string keys.
When should I use a discriminated union instead of optional fields?
Use a discriminated union whenever some fields only make sense in certain states, such as data on success and error on failure. It prevents impossible combinations and lets the compiler check that every state is handled.
Checklist for advanced types
- Model states with discriminated unions, not boolean flags and optional fields.
- Derive types with
keyof,T[K]and mapped types instead of copying definitions. - Use
T[K], notT, as the property type in a mapped type. - Reach for built-in utility types before writing your own mapped type.
- Use key remapping with
asto rename or filter properties. - Keep template literal unions small.
Types that derive from a single source of truth are easier to change safely, and the same idea applies well beyond TypeScript. If your team wants help designing a typed frontend or API layer, Vectorkub builds web applications with TypeScript end to end. For choosing between object type declarations, see interfaces vs type aliases.
