この記事は型安全な型定義とそれが可能な実装に関する考察であり利用者側にとっての使いやすさなどは考慮していません。ここに型安全ではない例としてあげた、もしくそれと同じような定義となっているライブラリも型推論に頼っていれば基本的に安全な事と、使いやすさを考えると仕方ない部分があることは理解しておりそのようなライブラリに対する批判でもありません。
曖昧な知識で考えた自分用メモのようなものなので間違っている所がある可能性は高いですし参考にはしないで下さい。間違ってる所があればTwitterなどで指摘してくださると嬉しいです。
以下のような関数定義があるとする。
function f<T extends object, K extends keyof T>(obj: T, keys: Keys<K>): Result<T, K>;
この時型コンストラクタResult
の性質によって場合分けし、Keys
、つまりオブジェクトのキー集合の安全な受け取り型を考える。
またobj: T
とkeys: 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"])
受け取り方の条件は上記のまとめから以下のようになる。
これを満たす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 = Omit
のomit
関数である。
なぜなら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 })
は矛盾を起こすからである。
ではpick
とomit
のResult
であるPick
とOmit
の違いはどこだろう。どのようなResult
の時にオブジェクトでキー集合を受け取り、またどのような時に配列でキー集合を受け取ればいいのだろうか。
Pick
とOmit
の大きな違いは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>
が成り立つ。だからomit
はArray
で受け取るべきだし、pick
はRecord<K, null>
で受け取るべきである。
Result
がK
に対して共変であればArray<K>
でキーの集合を受け取り、反変であればRecord<K, null>
でキー集合を受け取ると良い。