NoInfer<T>の活用例見つけた

こんにちは。この記事は[株式会社カオナビ Advent Calendar 2024](https://qiita.com/advent-calendar/2024/kaonavi)の3日目の記事です。

## `NoInfer<T>`

皆さんは、TypeScriptの機能である`NoInfer<T>`をご存知でしょうか。これはTypeScript 5.4から追加された新機能であり、おおよそ次のようなものです。

- 型としては`NoInfer<T>`は`T`と同じだが、型推論に違いがある。
- `NoInfer<T>`の`T`部分に型変数が含まれている場合でも、`T`部分は型推論に使われない。
- `NoInfer<T>`は主にジェネリック関数の引数の型として使われる。

[TypeScript 5.4のリリースノート](https://devblogs.microsoft.com/typescript/announcing-typescript-5-4/)では`NoInfer<T>`の例として次のようなものが紹介されていましたので、改めて解説します。

```ts
function createStreetLight<C extends string>(colors: C[], defaultColor?: C) {
    // ...
}

createStreetLight(["red", "yellow", "green"], "red");
```

この例は、`createStreetLight`の引数`colors`が文字列（のリテラル型）の配列であり、`defaultColor`はその中から選ぶという意味に解釈できます。`Cを型引数とすることで、可能な文字列を呼び出し側で指定することができます。

この例だと`C`は`"red" | "yellow" | "green"`に推論されるでしょう。

この例の問題は、`colors`に含まれない文字列を`defaultColor`に渡しても型エラーとはならず、意図と異なってしまうことです。

```ts
// 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`です。このように直すことができます。

```ts
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に入れつつ返します。

```ts
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`が入っていた」のではなく「値が入っていなかった」であることを確実にしています。

使用例は次のような感じです。

```ts
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
```

この例では問題なかったのですが、次の場合に型推論に問題が生じました。

```ts
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`のシグネチャ部分を見直すと次のようになっています。

```ts
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`の出番となります。このように関数シグネチャを書き換えてみます。

```ts
export function getOrInsert<K, T extends {}>(
  map: Map<K, T>,
  key: K,
  getValue: () => NoInfer<T>,
): T
```

すると目論見通り、`subMap`の型は`Map<string, number>`になります。`getValue`からの推論が消えたことで`map`からの推論が働くようになったのでしょう。

```ts
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>`の定番の利用パターンなのかもしれません。