Skip to content
academia.sh

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 readonly and ? modifiers can be added during mapping and removed with the -readonly and -? notations.
  • The as notation computes the produced field’s name; when the name is never, 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.

Start typing to search.

↑↓ Esc navigate · open · close