reduceの「型指定」とは何を決めるものか
reduce は、配列を「1つの値」に畳み込むメソッドです。
TypeScript的に一番大事なのは、「途中経過(累積値:acc)の型をどう扱うか」です。
この「累積値の型」を、TypeScriptに推論させるのか、自分で明示的に指定するのか——それが「reduceの型指定」です。
const nums = [1, 2, 3]; // number[]
const sum = nums.reduce((acc, n) => acc + n, 0);
// sum: number
TypeScriptこの例では、初期値 0 が number なので、acc も number、戻り値も number と推論されます。
ところが、「配列の要素型と累積値の型が違う」「初期値が空配列や空オブジェクト」になると、一気に難しくなります。
reduceの基本シグネチャと型推論の流れ
「初期値あり」のときの型の流れ
reduce のシグネチャをざっくり書くと、こういうイメージです。
array.reduce(
(acc: A, cur: T) => A,
initialValue: A
): A
TypeScriptここで、
Tは「配列の要素型」Aは「累積値(acc)の型」
です。
TypeScriptは、次の情報から A を推論しようとします。
- 初期値
initialValueの型 - コールバックの戻り値の型
たとえば、こうです。
const nums = [1, 2, 3]; // number[]
const sum = nums.reduce((acc, n) => acc + n, 0);
// acc: number, n: number, 戻り値: number → sum: number
TypeScriptここでは、初期値 0 が number なので、acc も number、戻り値も number と推論され、
結果として sum は number になります。
「要素型と違う型に畳み込みたい」ときの型指定
典型例:number[] をオブジェクトに畳み込む
const nums = [1, 2, 3]; // number[]
const map = nums.reduce((acc, n) => {
acc[n] = `No.${n}`;
return acc;
}, {} as Record<number, string>);
TypeScriptこの書き方、現場でよく見ますが、実は「型キャスト(as)」に頼っていて、あまり良くありません。
{} の型はデフォルトだと {} で、インデックスシグネチャも何もない「ほぼ何もできない型」です。
それを as Record<number, string> とキャストして無理やり使っているので、
TypeScriptの型チェックを一部すり抜けてしまう可能性があります。
ここで効いてくるのが、「reduceにジェネリクス型パラメータを明示的に渡す」というテクニックです。
ジェネリクスで累積値の型を指定する
const nums = [1, 2, 3]; // number[]
const map = nums.reduce<Record<number, string>>((acc, n) => {
acc[n] = `No.${n}`;
return acc;
}, {});
// map: Record<number, string>
TypeScriptここでやっていることは、
reduce<Record<number, string>>と書いて、「累積値の型は Record<number, string> だ」と宣言する- 初期値
{}は、その型の「空の値」として扱われる - コールバックの
accもRecord<number, string>として型チェックされる
という流れです。
これにより、
acc[123] = "x"のような操作はOK- 存在しないプロパティに変な型を入れようとするとエラー
- キャスト(as)に頼らないので、型安全性が高い
という、かなり気持ちいい状態になります。
「空配列」「空オブジェクト」を初期値にするときの落とし穴
そのままだと never[] や {} になってしまう
const nums = [1, 2, 3];
const result = nums.reduce((acc, n) => {
acc.push(n * 2);
return acc;
}, []);
TypeScript一見よくありそうなコードですが、[] の型はデフォルトだと never[] です。
「何も入っていないから、要素型が推論できない → 何も入れられない配列(never[])」という扱いになります。
その結果、acc.push(n * 2) のところで「never[] に要素は push できない」というエラーになりがちです。
これを避けるために、つい as number[] と書きたくなりますが、
さっきと同じく「キャスト頼み」になってしまいます。
reduceの型パラメータで素直に指定する
const nums = [1, 2, 3];
const result = nums.reduce<number[]>((acc, n) => {
acc.push(n * 2);
return acc;
}, []);
// result: number[]
TypeScriptここで reduce<number[]> と書くことで、
- 累積値
accの型はnumber[] - 初期値
[]もnumber[]として扱われる pushも型安全に使える
という状態になります。
「空配列」「空オブジェクト」を初期値にする reduce は、
「型パラメータで累積値の型を明示する」のがベストプラクティスです。
reduceの型指定とジェネリクスの関係
reduceはジェネリック関数
reduce は内部的にジェネリック関数として定義されています。
ざっくり書くと、こんなイメージです。
reduce<A>(
callbackfn: (previousValue: A, currentValue: T, index: number, array: T[]) => A,
initialValue: A
): A
TypeScriptここで A が「累積値の型」、T が「配列の要素型」です。
TypeScriptは通常、この A を「初期値」と「コールバックの戻り値」から推論しますが、
推論がうまくいかない(または危険な)ケースでは、reduce<A> のように自分で A を指定してあげると、型が一気に安定します。
これは、TypeScriptのジェネリクスの一般的な使い方と同じで、
「推論に任せるか」「明示的に渡すか」を状況に応じて選ぶスタイルです。
初心者がまず押さえておきたい「reduceの型指定」の感覚
reduceの型指定で一番大事なのは、次の2つです。
- 「累積値(acc)の型は何か?」を意識すること
- 「初期値が空配列・空オブジェクトのときは、ジェネリクスで型を明示すること」
具体的には、
// 要素型と累積値の型が同じ → 推論に任せてOK
nums.reduce((acc, n) => acc + n, 0);
// 要素型と累積値の型が違う → reduce<累積値の型> を書く
nums.reduce<Record<number, string>>((acc, n) => { ... }, {});
nums.reduce<number[]>((acc, n) => { ... }, []);
TypeScriptという使い分けです。
「reduceを書くときは、まず“accの型”を頭の中で決める」
「空の値を初期値にするときは、型パラメータでちゃんと教えてあげる」
この2つの癖がつくと、reduceは一気に“怖いメソッド”から“型安全な変換マシン”に変わります。
