Kgtkr's Blog

TypeScriptでオブジェクトのキー集合の値表現に関する考察

2019/08/27
typescript

初めに

この記事は型安全な型定義とそれが可能な実装に関する考察であり利用者側にとっての使いやすさなどは考慮していません。ここに型安全ではない例としてあげた、もしくそれと同じような定義となっているライブラリも型推論に頼っていれば基本的に安全な事と、使いやすさを考えると仕方ない部分があることは理解しておりそのようなライブラリに対する批判でもありません。
曖昧な知識で考えた自分用メモのようなものなので間違っている所がある可能性は高いですし参考にはしないで下さい。間違ってる所があればTwitterなどで指摘してくださると嬉しいです。

オブジェクトのキーの集合の値表現

以下のような関数定義があるとする。

function f<T extends object, K extends keyof T>(obj: T, keys: Keys<K>): Result<T, K>;

この時型コンストラクタResultの性質によって場合分けし、Keys、つまりオブジェクトのキー集合の安全な受け取り型を考える。
またobj: Tkeys: Keys<K>を受け取りobjに存在しないキーを削除したSetを返すkeysToSet(obj, keys)が存在するものとする。

安全でないキー集合の受け取り型の例

まず安全ではない受け取り方の例を示す。
よくある例としてキー集合として配列を受け取るpick関数がある。(例: lodash)
これは以下のような関数である。

pick({x: 1, y: 2, z: 3}, ['x']); // {x: 1}

この時Keys = Array, Result = Pickとなり、型定義は以下のようになるだろう。

function pick<T extends object, K extends keyof T>(obj: T, keys: Array<K>): Pick<T, K>;

しかしこの型定義は型安全ではない。なぜなら以下のような書き方をされると値は{x: 1}なのに型は{x: number, y: number, z: number}となり矛盾が発生するからである。

pick<{x: number, y: number, z: number}, "x" | "y" | "z">({x: 1, y: 2, z: 3}, ["x"]);

このような矛盾が発生するのはArray<"x" | "y" | "z">という型は配列の全ての要素が"x" | "y" | "z"を満たすという事を表しているのに、この関数では{ "x", "y", "z" }という型集合の全ての要素が配列の要素に存在することを期待しているからです。
また逆に型に矛盾を起こさないがコンパイルエラーになる例としてpick<{x: number, y: number, z: number}, "x">({x: 1, y: 2, z: 3}, ["x", "y"])などが存在する。
以上の事をまとめると以下のようになる。

Array<"x" | "y">に許される値の例(keysToSet適用済み):
Set([]), Set(["x", "y"])

keysが実際に期待している値の例(keysToSet適用済み):
Set(["x", "y"]), Set(["x", "y", "z"])

pick関数で型安全にキーを受け取る

受け取り方の条件は上記のまとめから以下のようになる。

  1. unionの型集合の全ての要素の型が存在するSetに変換可能
  2. 変換後のSetにunionの型集合に存在しない要素が含まれていても良い

これを満たすKeysの一つにRecord<K, null>がある。
Record<"x" | "y", null>{ x: null }は代入できないので1を満たすし、{ x: null, y: null, z: null }を代入できるので2を満たす。
つまりこのようにkeysを受け取ることで安全にpick関数を定義出来る。

配列で受け取るほうが安全な例

しかしいつでもRecord<K, null>が安全なわけではない。逆にArray<K>が安全な例も存在する。
それの例がResult = Omitomit関数である。
なぜならomit<{x: number, y: number, z: number}, "x" | "y">({x: 1, y: 2, z: 3}, [])は型に矛盾を起こさないが、omit<{x: number, y: number, z: number}, "x">({x: 1, y: 2, z: 3}, { x: null, y: null })は矛盾を起こすからである。

オブジェクトと配列、どっちで受け取るか

ではpickomitResultであるPickOmitの違いはどこだろう。どのようなResultの時にオブジェクトでキー集合を受け取り、またどのような時に配列でキー集合を受け取ればいいのだろうか。
PickOmitの大きな違いはKに対して反変性を持つか共変性を持つかである。
A extends Bの時、Pick<T, B> extends Pick<T, A>なので反変、A extends Bの時、Omit<T, A> extends Omit<T, B>なので共変である。
またgetのみを考えた場合、Arrayは共変、Record<K, null>は反変となる。
つまり、Array<A> extends Array<B>の時、Omit<T, A> extends Omit<T, B>が成り立つし、Record<A, null> extends Record<B, null>の時、Pick<T, A> extends Pick<T, B>が成り立つ。だからomitArrayで受け取るべきだし、pickRecord<K, null>で受け取るべきである。

結論

ResultKに対して共変であればArray<K>でキーの集合を受け取り、反変であればRecord<K, null>でキー集合を受け取ると良い。