こんにちは。この記事は株式会社カオナビ 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>の定番の利用パターンなのかもしれません。