scalable/craft/ts types in anger/lesson 05 cph / /
lesson 05 / 06 · 12 min · updated ·

mapped types & key remapping

Partial, Pick, Required — built from primitives. key remapping with `as`. the workhorse of the type system.

the iteration of the type world

a mapped type is a for loop for types. you take a union of keys, walk each one, and produce a new type whose properties are derived from those keys. it sounds abstract — and then you realize every utility type in the standard library is built this way. Partial, Pick, Required, Record, Readonly — all mapped types, most of them one line.

Partial, from the source
// from the standard library — three tokens of mapped type.
type Partial<T> = { [K in keyof T]?: T[K] };

interface User { id: string; name: string; age: number }

type UserPatch = Partial<User>;
//   ^? { id?: string; name?: string; age?: number }

read it left to right: for every key K in keyof T, produce an optional property of the same value type. one ? after the bracket. that's the whole feature. ten characters of syntax, applied uniformly, gives you a type that mirrors the original with every field optional.

the four modifiers

a mapped type can add or remove two modifiers: optionality (?) and immutability (readonly). a leading - removes the modifier instead of adding it. four knobs, infinite combinations.

modifier knobs
type Required<T> = { [K in keyof T]-?: T[K] };
type Readonly<T> = { readonly [K in keyof T]: T[K] };
type Mutable<T>  = { -readonly [K in keyof T]: T[K] };

interface User { id?: string; name: string }

type Solid    = Required<User>;        //  { id: string; name: string }
type Locked   = Readonly<User>;        //  { readonly id?: string; readonly name: string }
type Editable = Mutable<Locked>;       //  { id?: string; name: string }
memory peg no sign in front = add. - in front = remove. it works the same for ? and readonly. the standard library's Required uses -? to strip optional. Mutable isn't shipped, but it's a one-liner you'll write at some point.

Pick and Omit

Pick is a mapped type whose key set is a subset. Omit is Pick over the complement of a key set, computed with Exclude. both end up doing the obvious thing — which is the point: the standard tools are short enough to read in one glance.

Pick
type Pick<T, K extends keyof T> = { [P in K]: T[P] };

interface User { id: string; name: string; age: number; passwordHash: string }

type SafeUser = Pick<User, "id" | "name">;
//   ^? { id: string; name: string }
Omit, in terms of Pick
// Omit = Pick with a complementary key set.
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

interface User { id: string; name: string; passwordHash: string }

type SafeUser = Omit<User, "passwordHash">;
//   ^? { id: string; name: string }

key remapping with as

until 4.1, mapped types could only re-type values, not rename keys. then the as clause arrived and turned mapped types into the swiss army knife they are today. every key passes through the as expression, and the result becomes the new key.

renaming keys
// the 'as' clause maps each key through a type-level transform.
type Getters<T> = {
  [K in keyof T & string as `get${Capitalize<K>}`]: () => T[K];
};

interface User { id: string; name: string }

type UserGetters = Getters<User>;
//   ^? { getId: () => string; getName: () => string }

the same trick lets you filter: map a key to never and it drops out of the result entirely. never in a key position is the type-system's silent continue.

filtering by value type
// drop keys whose value isn't a function.
type Methods<T> = {
  [K in keyof T as T[K] extends (...a: any[]) => any ? K : never]: T[K];
};

interface Service {
  url: string;
  timeout: number;
  fetch(): Promise<unknown>;
  cancel(): void;
}

type Api = Methods<Service>;
//   ^? { fetch(): Promise<unknown>; cancel(): void }

check yourself

mapped types — 4 questions

trace the mapping. pay attention to modifiers and to how 'as never' silently drops keys.

modifier strip
interface Form {
  id?: string;
  name?: string;
  age?: number;
}
type Submitted = { [K in keyof Form]-?: Form[K] };
//   ^?

what is `Submitted`?

three uses in the wild

patterns from real codebases

three production-shape uses of mapped types you'll write or read this year.

type FormState<T> = {
  values: T;
  errors: { [K in keyof T]?: string };
  touched: { [K in keyof T]: boolean };
};

interface SignUp { email: string; password: string }

type S = FormState<SignUp>;
tsc sees
// S = {
//   values:  { email: string; password: string };
//   errors:  { email?: string; password?: string };
//   touched: { email: boolean; password: boolean };
// }
//
// add a field to SignUp, all three
// nested types update automatically.

every form library worth using does exactly this. fields stay in sync with the source-of-truth shape.

recursive mapping

mapped types can call themselves on nested values to walk deep into a shape. DeepReadonly is the classic. it's three lines and it's the same idea as DeepPartial from the previous lesson — a base case, a recursive case, no ceremony.

DeepReadonly
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};

interface Config {
  server: { host: string; port: number };
  flags: { ai: boolean };
}

type Frozen = DeepReadonly<Config>;
//   ^? {
//        readonly server: { readonly host: string; readonly port: number };
//        readonly flags:  { readonly ai: boolean };
//      }
watch out recursive mapped types can blow the depth budget on heavily nested shapes (think: api responses with embedded sub-resources eight levels deep). when that happens, stop trying to be clever in the type system and write a runtime helper.

what you took away

further reading

scalable labs·cvr 30091604·github·linkedin·hello@scalable.dk