TypeScript上級講座 #2 Mapped types

読了 6分

#1 keyofとtypeofでオブジェクトのキーをunionに集める方法を見ました。その上にもう一段乗せれば — オブジェクト型を丸ごと変換するmapped typesになります。PartialRequiredReadonlyのようなユーティリティ型の正体がまさにこれです。

基本形 #

mapped typeは次のような一行の文法です。

mapped type — 基本
type MyType<T> = {
  [K in keyof T]: T[K];
};

// MyType<{ a: string; b: number }>
// = { a: string; b: number }

[K in keyof T]が要点です。「Tのキー K一つ一つについて」という意味です。その中のT[K]#1で見たインデックスアクセス — そのキーで取り出した値の型。

上の例は入力をそのまま複製するだけですが、T[K]の位置に違う型を書くと変換が始まります。

値の型をすべてstringに
type Stringify<T> = {
  [K in keyof T]: string;
};

type S = Stringify<{ id: number; age: number; ok: boolean }>;
// { id: string; age: string; ok: string }

Partialを自分で作ってみる #

Partial<T>はすべてのフィールドをoptionalにする組み込み型です。modifierを一つ追加するだけで自分で書けます。

Partial直接実装
type MyPartial<T> = {
  [K in keyof T]?: T[K];
};

type Patch = MyPartial<{ id: string; name: string; age: number }>;
// { id?: string; name?: string; age?: number }

?が要点です。[K in keyof T]?のようにキーの位置の後ろに付けると、すべてのフィールドがoptionalになります。

Required — すべてのフィールドを必須に #

逆方向。modifierに-?を付けるとoptional印が削除されます。

Required直接実装
type MyRequired<T> = {
  [K in keyof T]-?: T[K];
};

type Strict = MyRequired<{ id?: string; name?: string }>;
// { id: string; name: string }

?-?が一対です。組み込みのPartial/Requiredは正確にこの形で定義されています。なんとなく一生覚えなければならない魔法のようだったものが、実は一行のmapped typeでした。

Readonlyも同じ方式 #

値を読み取り専用にするときはreadonly modifierを付けます。

Readonly直接実装
type MyReadonly<T> = {
  readonly [K in keyof T]: T[K];
};

type Frozen = MyReadonly<{ id: string; name: string }>;
// { readonly id: string; readonly name: string }

-readonlyも可能です。外部ライブラリの型がreadonlyで解きたいときにたまに使います。

readonly削除
type Mutable<T> = {
  -readonly [K in keyof T]: T[K];
};

type M = Mutable<Readonly<{ id: string }>>;
// { id: string }

PickOmitも同じ家族 #

Pick<T, K>Omit<T, K>もmapped typeです。違いは回すキー集合がどこから来るかだけです。

Pick直接実装
type MyPick<T, K extends keyof T> = {
  [P in K]: T[P];
};

type UserBasic = MyPick<{ id: string; name: string; age: number }, 'id' | 'name'>;
// { id: string; name: string }

[P in K]keyof Tではなく外部から受け取ったunion Kを回したのです。mapped typeは実は「どんなunionでもそのままキーとして使える」道具だということが見えます。

Omitは少し手間がかかります。組み込みのExcludeを使ってキー集合を引き算します。

Omit直接実装
type MyOmit<T, K extends keyof T> = {
  [P in Exclude<keyof T, K>]: T[P];
};

type WithoutAge = MyOmit<{ id: string; name: string; age: number }, 'age'>;
// { id: string; name: string }

Exclude<U, V>はunion UからVに該当するメンバーを除きます。これは#3 conditional typesで直接書いてみます。

キーを別の名前に変える — as#

ここからは組み込みユーティリティには無い、自分で作ってこそ意味のある機能です。as節を使うとキー名を変えることができます。

setter名の自動生成
type Setters<T> = {
  [K in keyof T as `set${Capitalize<string & K>}`]: (value: T[K]) => void;
};

type UserSetters = Setters<{ name: string; age: number }>;
// {
//   setName: (value: string) => void;
//   setAge: (value: number) => void;
// }

三つの道具が組み合わさっています。

  1. template literal type`set${...}`で文字列型を合成。#4で本格的に。
  2. Capitalize<S> — 組み込みヘルパー。最初の文字を大文字に。
  3. string & K — symbol/numberキーをふるい分けてstringキーだけ。

nameキーをsetNameに変え、その値の型をsetter関数にします。ライブラリを作るとき、こういう自動生成パターンがよく登場します。

as + never = キー削除 #

asの位置にneverを置くと、そのキーは結果の型から消えます。

関数フィールドだけ残す
type FunctionsOnly<T> = {
  [K in keyof T as T[K] extends Function ? K : never]: T[K];
};

type Mixed = {
  id: string;
  name: string;
  greet: () => void;
  fetch: () => Promise<void>;
};

type Methods = FunctionsOnly<Mixed>;
// { greet: () => void; fetch: () => Promise<void> }

値の型が関数でなければキーをneverに変えて削除。これが可能になることでmapped typeの表現力が一段階上がります。

組み込みユーティリティの整理 — 今見えるもの #

この時点で基礎講座 #7で見たユーティリティ型の半分以上を自分で書けます。

ユーティリティ定義
Partial<T>{ [K in keyof T]?: T[K] }
Required<T>{ [K in keyof T]-?: T[K] }
Readonly<T>{ readonly [K in keyof T]: T[K] }
Pick<T, K>{ [P in K]: T[P] }
Record<K, V>{ [P in K]: V }

Record<K, V>もmapped typeだということに意味があります。下の一行で定義されます。

Record直接実装
type MyRecord<K extends keyof any, V> = {
  [P in K]: V;
};

type Roles = MyRecord<'admin' | 'user' | 'guest', boolean>;
// { admin: boolean; user: boolean; guest: boolean }

K extends keyof anyが見えますが、これは「string | number | symbol — オブジェクトのキーになり得るすべて」という意味です。よく使う慣用句です。

もう一段 — 深く入る変換 #

オブジェクトがネストされているとき、再帰的に変換したい場合があります。mapped typeを自分自身で呼ぶことで可能です。

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

type Settings = {
  theme: { color: string; font: string };
  user: { id: string; flags: { admin: boolean } };
};

type Frozen = DeepReadonly<Settings>;
// すべてのネストされたオブジェクトまでreadonlyに

T[K] extends object ? ... : ...#3で本格的に扱うconditional typeです。mapped typeの中で「値がオブジェクトなら再びmapped、そうでなければそのまま」という分岐を作ったのです。二つの道具が出会うと表現力が爆発的に上がります。

同じパターンでDeepPartialもよくあります。

DeepPartial — フォームpatchのようなケース
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object
    ? DeepPartial<T[K]>
    : T[K];
};

部分更新、設定オーバーライド、フォームpatchのようなケースに合います。ライブラリがこの形をよく採用します。

落とし穴 — 関数と配列もobjectに含まれる #

T[K] extends objectは意外に広い条件です。関数、配列、Date、Mapすべてobjectです。DeepReadonlyのようなものを書くとき関数まで再帰すると壊れることがあります。普通は次のようにプリミティブ値と関数を明示的に除外します。

実戦的なDeepReadonly
type Primitive = string | number | boolean | null | undefined | bigint | symbol;

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

この段階まで来るとconditional typesに慣れていないとスムーズに読めません。次の記事で本格的に手に馴染ませます。

まとめ #

今回整理した内容:

  • mapped type基本 — { [K in keyof T]: T[K] }
  • modifier — ? / -? (optional)、readonly / -readonly (readonly)
  • PartialRequiredReadonlyPickRecordがすべてmapped type
  • as節でキー名を変える — `as `set${...}`のようなパターン
  • as ... neverでキー削除
  • 自分自身を呼ぶと深い再帰変換 (DeepReadonly/DeepPartial)
  • 再帰時にプリミティブ値/関数の除外に注意

次の記事(#3 Conditional typesとinfer)では上で軽く登場したT extends U ? X : Yの文法を本格的に扱います。inferキーワードまで身につければ、ReturnTypeParametersAwaitedのような組み込みを直接書くところまでいきます。

X