こんにちは。この記事は株式会社カオナビ Advent Calendar 2024の3日目の記事です。
NoInfer<T>
皆さんは、TypeScriptの機能であるNoInfer<T>
をご存知でしょうか。これはTypeScript 5.4から追加された新機能であり、おおよそ次のようなものです。
- 型としては
NoInfer<T>
はT
と同じだが、型推論に違いがある。 -
NoInfer<T>
のT
部分に型変数が含まれている場合でも、T
部分は型推論に使われない。 -
NoInfer<T>
は主にジェネリック関数の引数の型として使われる。
TypeScript 5.4のリリースノートではNoInfer<T>
の例として次のようなものが紹介されていましたので、改めて解説します。
function createStreetLight<C extends string>(colors: C[], defaultColor?: C) {
// ...
}
createStreetLight(["red", "yellow", "green"], "red");
この例は、createStreetLight
の引数colors
が文字列(のリテラル型)の配列であり、defaultColor
はその中から選ぶという意味に解釈できます。`Cを型引数とすることで、可能な文字列を呼び出し側で指定することができます。
この例だとC
は"red" | "yellow" | "green"
に推論されるでしょう。
この例の問題は、colors
に含まれない文字列をdefaultColor
に渡しても型エラーとはならず、意図と異なってしまうことです。
// Oops! This undesirable, but is allowed!
createStreetLight(["red", "yellow", "green"], "blue");
この場合、C
が"red" | "yellow" | "green" | "blue"
となっており、「defaultColor
はcolors
の中から選ぶ」という意図した挙動になっていません。
こうなる理由は、TypeScriptから見ると、colors
もdefaultColor
がどちらもC
の推論材料になっているからです。color
から「C
には"red" | "yellow" | "green"
を入れていい」ということが推論され、defaultColor
から「C
には"blue"
を入れていい」ということが推論されます。
これを意図通りに直すために使えるのがNoInfer
です。このように直すことができます。
function createStreetLight<C extends string>(colors: C[], defaultColor?: NoInfer<C>) {
// ...
}
createStreetLight(["red", "yellow", "green"], "blue");
// ~~~~~~
// error!
// Argument of type '"blue"' is not assignable to parameter of type '"red" | "yellow" | "green" | undefined'.
このように引数defaultColor
の型をNoInfer<C>
とした場合、これは「C
なんだけど、C
を推論するときの材料に使うなよ」という意味になります。
その結果、「C
には"blue"
が入れられる」という推論が発生しなくなり、color
のみからC
の型が推論されるため、意図通りに型エラーが発生します。
どうでしょうか。以上の説明を読めば、NoInfer<T>
の挙動はなんとなく分かるかもしれません。しかし、日頃のコーディングにどう活用すればいいのか思いつかない方もいるでしょう。筆者もそうで、意味はわかるけど活用できる場面はありませんでした。
しかし、今日業務でコードを書いていたところ偶然にもNoInfer<T>
が活用できる機会が生じたので、皆さんにおすそ分けしたいと思います。
本題: getOrInsert
でNoInfer<T>
を活用する
今回NoInfer<T>
の活用例として紹介するのはgetOrInsert
関数です。Map
に対するユーティリティ関数で、Mapに指定したキーがあればその中身をそのまま返し、まだない場合はgetValue
で値を作ってそれをMapに入れつつ返します。
export function getOrInsert<K, T extends {}>(
map: Map<K, T>,
key: K,
getValue: () => T,
): T {
const value = map.get(key);
if (value !== undefined) {
return value;
}
const newValue = getValue();
map.set(key, newValue);
return newValue;
}
本題とは関係ありませんが、型引数でT extends {}
という指定になっているのがこだわりポイントです。これにより、「Map
にundefined
が入っている」という状況を排除し、map.get
の結果がundefined
である場合は「undefined
が入っていた」のではなく「値が入っていなかった」であることを確実にしています。
使用例は次のような感じです。
const myMap = new Map<string, number>();
const v1 = getOrInsert(myMap, "uhyo", () => 123);
console.log(v1); // 123
const v2 = getOrInsert(myMap, "uhyo", () => 0);
console.log(v2); // 123
const v3 = getOrInsert(myMap, "other", () => 555);
console.log(v3); // 555
この例では問題なかったのですが、次の場合に型推論に問題が生じました。
const mapOfMaps = new Map<string, Map<string, number>>();
const subMap = getOrInsert(mapOfMaps, "uhyo", () => new Map());
// ^? Map<any, any>
このように、「MapのMap」を作ることはよくあります。筆者はこのケースのためにgetOrInsert
を実装しました。
ここで、subMap
の型に問題が生じます。意図としてはMap<string, number>
となってほしいところなのですが、subMap
はMap<any, any>
になってしまっています。
そうなってしまう理由は、new Map()
の結果がMap<any, any>
だからです。Map
は歴史的事情からか、型引数のデフォルトがany
に設定されています。そのため、型引数を明示的に指定するか、あるいはcontextual typeがない限り、Map<any, any>
ができてしまいます。
getOnInsert
のシグネチャ部分を見直すと次のようになっています。
export function getOrInsert<K, T extends {}>(
map: Map<K, T>,
key: K,
getValue: () => T,
): T
察するに、T
の型推論に引数getValue
が使われた結果として、T
にMap<any, any>
が入ってしまったのでしょう。引数map
からはMap<string, number>
が推論されるはずですが、getValue
からの推論が優先されているようです。ここの細かい機序は追いきれていないのですが、TypeScriptの型推論はヒューリスティクスの塊なのでこういうこともあるでしょう。
NoInfer<T>
を使って型推論を改善する
ということで、getValue
由来でMap<any, any>
が入ってきてしまうのであれば、NoInfer
の出番となります。このように関数シグネチャを書き換えてみます。
export function getOrInsert<K, T extends {}>(
map: Map<K, T>,
key: K,
getValue: () => NoInfer<T>,
): T
すると目論見通り、subMap
の型はMap<string, number>
になります。getValue
からの推論が消えたことでmap
からの推論が働くようになったのでしょう。
const mapOfMaps = new Map<string, Map<string, number>>();
const subMap = getOrInsert(mapOfMaps, "uhyo", () => new Map());
// ^? Map<string, number>
まとめ
この記事では、説明を聞いてもいまいち使いどころが思い浮かばないNoInfer<T>
について、業務で偶然出会った使い方を解説しました。
奇しくもTypeScript公式の説明と同じく、デフォルト値を扱う場面でNoInfer<T>
が有効に働きました。これがNoInfer<T>
の定番の利用パターンなのかもしれません。