🗺️

Mapped Type の型推論における落とし穴とテクニック

2022/07/05に公開

前置き

初めまして。Zenn 初投稿です。
半年くらい前から TypeScript を使い始めて、最近 Deno で Cloudflare Workers をやるためのフレームワークを作っています:

https://github.com/hasundue/flash

結構ガチガチに型を書いているのですが、あまり調べても出てこないようなことに気付くことがあるので、まとめていきたいと思っています。
TypeScript にある程度慣れた人向けのちょっとマニアックな記事になると思います。

今回は Mapped Type の型推論について書いてみます。

問題

次のような、引数をそのまま返す identity 関数を考えます。

const identity = (arg: T) => arg;

const result = identity({
  foo: ["foo"],
  bar: ["bar", "bar"],
});

このときに、関数 identity の引数の型 T に対して、なるべく強い制約を与える、かつ、なるべく強い型推論を行う、ということを考えてみます。

ただし、引数となるオブジェクトのプロパティ数やキーの値 (foo, bar) はユーザーが自由に設定でき、事前には分からないとします。

与えたい制約は具体的には、

  • キーの型は string
  • 値はキーの配列になっている

だとします。

まずシンプルな

const identity1 = (arg: Record<string, string[]>) => arg;

の場合、result の型はそのまま Record<string, string[]> となります:

これだと、foo と bar を入れ替えたオブジェクトも許してしまいます:

これは Record のキーと値の間に制約関係が無いためです。

では Mapped Type を使うとどうでしょうか。

function identity2(arg: {
 [T in string]: T[]
}) {
  return arg;
}

一見良さそうですが…

先ほどの Record<string, string[]> と同じくキーと値の間に制約関係がありません。したがって、foo と bar を入れ替えたオブジェクトを同じように許してしまいます:

ではどうすれば良いのでしょうか。
正解はこうです:

解答

function identity3<U extends string>(arg: {
  [T in U]: T[]
}) {
  return arg;
}

解説

さて、identity2identity3 は何が違うのでしょうか。まず Mapped Type についておさらいしてみましょう。

https://www.typescriptlang.org/docs/handbook/2/mapped-types.html

こう書いてあります:

A mapped type is a generic type which uses a union of PropertyKeys (frequently created via a keyof) to iterate through keys to create a type:

つまり、[T in X]X は何らかのユニオン ("foo" | "bar" みたいなやつ) ですよ、と言っています。では identity2 はどうだったかというと、

function identity2(arg: {
 [T in string]: T[]
}) {
  return arg;
}

X の部分に string を入れていました。この定義自体はエラーではなかったので、string もユニオンだと言っても良いようです。

ではユニオンとしての string はどういうものかと言うと、あらゆる文字列リテラル型を列挙したもの、つまり無限個の文字列の集合だと考えられます。

identity2 は、その全ての要素 TT[] にマップするもの、ということになりますが、そんなものは実際には作れないよね、ということで、Record 型にフォールバックしてしまうのだと考えられます。

一方で正解の identity3 はどうだったかというと、

function identity3<U extends string>(arg: {
  [T in U]: T[]
}) {
  return arg;
}

string の代わりに U extends string を使っています。こうすることで、U は string の部分集合と見なされ有限の要素数を持てるようになるため、有効な Mapped Type になる、ということだと考えられます。

identity3{ foo: ["foo"], bar: ["bar"] } を渡した例では、"foo" | "bar"U にマッチし、その全ての要素が定義通りにマップされている、と判断されたことになります。この U は関数の中で使いまわせるため、いろいろ応用が効きます。

最後に

今回は以上です。分かっている方には当たり前の内容かもしれませんが、個人的には面白いと思ったので、記事にしてみました。お役に立てば幸いです。

今回のテクニックが実際にどう使われているか気になるかたは、flash のコードを覗いてみてください。

https://github.com/hasundue/flash

flash についても使えるレベルになったら宣伝記事を書こうと思っています。

変更履歴

  • 2022/07/05 14:30: 数学的に正確でない記述を修正しました

Discussion