Lesson 14 / 23
Mapped Types
Producing a new type from an existing one; adding and removing field modifiers, key remapping, filtering fields, and how mapping behaves on arrays and tuples.
Contents
The various shapes of a measurement record have so far been written out by hand: a read-only version for archived records, a version with optional fields for an update request, a summary carrying only a few fields for listing.
Each is a transform of the original type. When written by hand, they need to be updated together whenever the original type changes, and that is a maintenance debt easy to forget. A mapped type computes the transform at the type level: the field list stays in one place, and the derived types update themselves.
Basic Notation
A mapped type resembles a loop iterating over a set of keys:
interface Measurement { id: string; value: number; unit: "C" | "Pa" | "%"; } type Locked<T> = { readonly [A in keyof T]: T[A] }; type Patchable<T> = { [A in keyof T]?: T[A] }; const archived: Locked<Measurement> = { id: "s-01", value: 21.4, unit: "C", }; console.log(archived.value); const patch: Patchable<Measurement> = { value: 22.1 }; console.log(patch.value, patch.id);
Output:
21.4 22.1 undefined
If the line archived.value = 22.1; is added at the end of the file, after a blank
line:
o2.ts(20,10): error TS2540: Cannot assign to 'value' because it is a read-only property.
The notation has these parts. The [A in keyof T] part binds each member of the
keyof T union to the name A in turn — the type-level counterpart of the for loop
from the Programming Fundamentals course. T[A] after the colon is that field’s
original type. The readonly written at the front and the ? appended to the name
are modifiers applied to every field.
Because every field of Patchable<Measurement> is optional, the literal
{ value: 22.1 } is accepted; the patch.id access has type string | undefined, and
undefined appears in the output.
The counterparts of these two transforms already exist in the standard library
(Readonly and Partial); they are covered in the Utility Types lesson. The reason
they are written by hand here is to make the mechanism visible.
Removing a Modifier
Just as modifiers can be added, they can be removed. Removal is written by putting a minus sign in front of the modifier:
interface Measurement { readonly id: string; readonly value?: number; } type Resolved<T> = { -readonly [A in keyof T]-?: T[A] }; const full: Resolved<Measurement> = { id: "s-01", value: 21.4 }; full.value = 22.1; console.log(full.value.toFixed(1));
The output is 22.1. If the line
const missing: Resolved<Measurement> = { id: "s-02" }; is added at the end of the
file, after a blank line:
o4.ts(12,7): error TS2741: Property 'value' is missing in type '{ id: string; }' but required in type 'Resolved<Measurement>'.
In type Measurement, id is read-only and value was both read-only and optional.
In type Resolved<Measurement>, both are writable and required. The result is that the
full.value.toFixed(1) call needs no narrowing: the field is now number, not
number | undefined.
This transform’s use case is representing the completed form of a partial record. Rather than writing a separate type for the record obtained after defaults have been applied, it is computed from the original type.
Key Remapping
The as notation lets the name of the produced field be changed:
interface Measurement { id: string; value: number; unit: "C" | "Pa" | "%"; } type Readers<T> = { [A in keyof T as `read${Capitalize<string & A>}`]: () => T[A]; }; const reader: Readers<Measurement> = { readId: () => "s-01", readValue: () => 21.4, readUnit: () => "C", }; console.log(reader.readValue().toFixed(1), reader.readId());
The output is 21.4 s-01.
The produced type declares a reader function for every field and takes the return type
from the original field: the call reader.readValue() gives number, the call
reader.readUnit() gives "C" | "Pa" | "%". The template literal notation used to
produce the name, along with the Capitalize transform, is the subject of the next
lesson; here it should only be seen that the name can be computed.
Remapping’s second and more powerful use is filtering out a field. When the
produced name is never, that field does not enter the output:
interface Measurement { id: string; value: number; unit: "C" | "Pa" | "%"; time: number; } type NumericFields<T> = { [A in keyof T as T[A] extends number ? A : never]: T[A]; }; const numeric: NumericFields<Measurement> = { value: 21.4, time: 1706000000000 }; console.log(numeric.value + numeric.time);
The output is 1706000000021.4. If the line
const wrong: NumericFields<Measurement> = { value: 21.4, time: 1, id: "s-01" }; is
added at the end of the file, after a blank line:
o7.ts(15,67): error TS2353: Object literal may only specify known properties, and 'id' does not exist in type 'NumericFields<Measurement>'.
The resulting type carries only the value and time fields; id and unit have
been filtered out. The notation T[A] extends number ? A : never is a conditional
type and is the subject of one of the next lessons; here its job is to keep the name
based on the field’s type or produce never and filter it out.
This type can be used in the signature of code that extracts a numeric summary of a measurement record. When a new numeric field is added to the record, the summary type grows on its own; when a string field is added, it is unaffected.
Mapping over Arrays and Tuples
When a mapping iterating over keyof T is applied to an array, nothing unexpected
happens: the result is still an array.
type Locked<T> = { readonly [A in keyof T]: T[A] }; const values: Locked<number[]> = [21.4, 22.1]; const range: Locked<[min: number, max: number]> = [-40, 85]; console.log(values.length, values[0], range[1]); console.log(values.map((d) => d * 2));
Output:
2 21.4 85 [ 42.8, 44.2 ]
Locked<number[]> is the type readonly number[], Locked<[number, number]> is a
read-only tuple. Array methods (map, length, indexed access) remain usable, mutating
methods are gone:
type Locked<T> = { readonly [A in keyof T]: T[A] }; const values: Locked<number[]> = [21.4, 22.1]; values.push(23.0);
u4.ts(4,8): error TS2339: Property 'push' does not exist on type 'readonly number[]'.
This behavior holds when the mapping’s source is directly keyof T. The compiler
recognizes this form and keeps arrays and tuples in their own kind. When the source is
written as any other expression, the recognition breaks:
type MapA<T> = { readonly [A in keyof T]: T[A] }; type MapB<T> = { readonly [A in keyof T & string]: T[A] }; declare const a: MapA<number[]>; declare const b: MapB<number[]>; const x: null = a; const y: null = b;
u11.ts(7,7): error TS2322: Type 'readonly number[]' is not assignable to type 'null'. u11.ts(8,7): error TS2322: Type 'MapB<number[]>' is not assignable to type 'null'.
The difference between the two diagnostics is decisive. MapA<number[]> resolved to
readonly number[] — the compiler is printing the result as an array. MapB<number[]>
stayed an unresolved mapping — the array structure was lost, replaced by an ordinary
object type carrying the array’s property names.
Practical rule: if a type’s structure needs to be preserved, the mapping source is
written as keyof T. If keys need to be filtered, the filtering is done with an
as remapping — the source stays keyof T, and the name computation lives on the
right side.
Mapping over Any Union
The mapping source does not have to be keyof T; any string union can be used:
type Unit = "C" | "Pa" | "%"; type Intervals = { [B in Unit]: [min: number, max: number]; }; const intervals: Intervals = { C: [-40, 85], Pa: [0, 200000], "%": [0, 100], }; console.log(intervals.C[1], intervals["%"][1]);
The output is 85 100. If the line
const missing: Intervals = { C: [-40, 85], Pa: [0, 200000] }; is added at the end of
the file, after a blank line:
o9.ts(15,7): error TS2741: Property '"%"' is missing in type '{ C: [number, number]; Pa: [number, number]; }' but required in type 'Intervals'.
This is the correct alternative to the index signature criticized in the Interfaces lesson. An index signature says “every string key is valid” and would not have counted access to a nonexistent key as an error. A mapped type, by contrast, fixes the key set exactly: a missing key produces a diagnostic, and so does an extra one.
When a new unit is added to the Unit union, updating the interval table becomes
mandatory. This is the data-table counterpart of the exhaustiveness checking built with
never in a discriminated union.
Summary
- A mapped type iterates over a key set and produces a new object type; derived types update together with the original type.
- The
readonlyand?modifiers can be added during mapping and removed with the-readonlyand-?notations. - The
asnotation computes the produced field’s name; when the name isnever, the field is filtered out of the output. - The mapping source does not have to be
keyof T; mapping can be done over any string union. - When the mapping source is directly
keyof T, arrays and tuples stay in their own kind; when the source is written as another expression, this structure is lost. - For a fixed key set, a mapped type is safer than an index signature: both a missing and an extra key are reported.
Next Step
In the previous two examples, a notation of the form T[A] extends number ? A : never
appeared and branched at the type level. This is the structure at the center of the
type system’s computing ability. The next lesson covers conditional types, distribution
rules, and the infer notation that extracts a piece from within a type.
To keep your progress and take notes, Log in
My notes
Log in to take notes.