JavaScript で Object.groupBy を使って配列をグループ化する
JavaScriptのObject.groupByで配列をキー別に分類し、reduceやMap.groupByと比較しながら、文字列化とnullプロトタイプ結果を解説します。
Object.groupBy(items, callback) は、1 回の呼び出しで配列をグループ化します。要素ごとにコールバックを 1 回実行し、返された値をグループ名として使い、それぞれの名前の下に該当する要素の配列を 1 つずつ持つオブジェクトを返します。
この処理のために手書きの reduce を今なお抱えているコードベースは多く、同じ数行のアキュムレータの定型コードがファイル間でコピーされていたり、たった 1 回の groupBy 呼び出しのために Lodash のインポートが残されていたりします。どちらも間違いではありませんし、今でも動作します。しかしネイティブメソッドが利用できる今、どちらも必須ではありません。
本記事では、具体的な問題(ステータス別の注文)に対してネイティブの呼び出しを示し、それが置き換える reduce と並べて比較し、代わりに Map.groupBy が適切なツールとなるのはどういう場合かを説明します。さらに、本番環境で人がつまずく 2 つの挙動、すなわちキーが黙って文字列に変換されること、そして結果オブジェクトに hasOwnProperty が存在しないことを詳しく見ていきます。
要点
Object.groupByはコールバックを 2 つの引数(element, index)で呼び出し、その戻り値をグループキーとして使用します。reduceのアキュムレータをObject.groupByに置き換えるのは可読性の改善であり、パフォーマンスの改善ではありません。より高速に動作することを期待する理由はありません。- グループ化キーが文字列でない場合、つまりオブジェクト、
Date、あるいは数値のまま保持したい数値の場合はMap.groupByを使いましょう。 Object.groupByで真偽値や数値によるグループ化を行うと、すべてのキーがプロパティキーへ強制変換されるため、文字列キー"true"や"40"が生成されます。Object.groupByが返すオブジェクトは null プロトタイプを持つため、result.hasOwnProperty(...)はTypeErrorを投げます。Object.hasOwnを使うか、スプレッド構文で結果をコピーしてください。
3 行でステータス別に注文をグループ化する
注文オブジェクトの配列があるとき、Object.groupBy はアキュムレータも存在チェックもなしに、単一の式でステータスをキーとするオブジェクトを生成します。
const orders = [
{ id: 1, status: "shipped", total: 40 },
{ id: 2, status: "pending", total: 15 },
{ id: 3, status: "shipped", total: 60 },
{ id: 4, status: "refunded", total: 22 },
];
const byStatus = Object.groupBy(orders, (order) => order.status);
console.log(Object.keys(byStatus)); // ["shipped", "pending", "refunded"]
console.log(byStatus.shipped.length); // 2
MDN の Object.groupBy() リファレンス にはその契約が記載されています。第 1 引数は配列だけでなく任意の iterable であり、結果は異なるキーごとに 1 つのプロパティを持ちます。グループは、その最初のメンバーが現れた順に並びます。各グループ内の要素は元のオブジェクトそのものであってコピーではないため、byStatus.shipped[0] を変更すると orders[0] も変更されます。
Object.groupBy のコールバックはどう動作するのか
コールバックは現在の要素とそのインデックスという 2 つの引数を受け取り、返した値がその要素のグループキーになります。ECMA-262 の Object.groupBy の定義 はまさにこの 2 つの引数を規定しており、map や filter にあるような第 3 引数の「配列全体」はありません。
キーは計算されるものなので、コールバックはフィールドの読み取りに限定されません。比較やインデックスから導かれるバケットなど、文字列を生成する任意の式が使えます。
const bySize = Object.groupBy(orders, (order) => (order.total >= 50 ? "large" : "small"));
// { small: [order 1, order 2, order 4], large: [order 3] }
const byHalf = Object.groupBy(orders, (_, index) => (index < 2 ? "first" : "second"));
// { first: [order 1, order 2], second: [order 3, order 4] }
すべての要素はちょうど 1 つのグループに入ります。2 つの要素が同じキーを生成した場合、それらは挿入順で同じ配列を共有します。
JavaScript の group by: reduce と Object.groupBy の比較
reduce のアキュムレータを Object.groupBy に置き換えると、初期オブジェクト、要素ごとの存在チェック、配列の生成、push、そして return がなくなります。残るのは、要素がどのグループに属するかを決める 1 行だけです。
以下は多くのコードベースにすでに存在するバージョンで、論理 null 代入 を使ってできる限りコンパクトに書いたものです。
const byStatusReduce = orders.reduce((acc, order) => {
acc[order.status] ??= [];
acc[order.status].push(order);
return acc;
}, {});
そしてネイティブメソッドによる同じ結果です。
const byStatus = Object.groupBy(orders, (order) => order.status);
どちらも同等のグループ化を生成します。違いは、読み手が頭の中に保持しなければならない情報量です。reduce の形式では、グループ化の意図がアキュムレータの初期値、条件付きの配列生成、ミューテーション、戻り値の 4 か所に分散しており、そのいずれもが微妙に間違っている可能性があります。ネイティブの形式では、レビューすべきものはキー関数だけです。
Object.groupBy は可読性の改善であり、パフォーマンスの改善ではありません。どちらのアプローチも入力を 1 回走査し、グループごとに 1 つの配列を確保します。ネイティブ呼び出しが、よく書かれた reduce より高速であると期待する理由はありません。ベンチマークのためではなく、定型コードを取り除くために選択してください。
Map.groupBy を代わりに使うべきなのはどんなときか
グループ化キーが文字列でない場合、つまりオブジェクト、Date、あるいは数値のまま保持したい数値の場合は Map.groupBy を使います。Map.groupBy() は同じ 2 引数のコールバックを取り、戻り値の型だけが異なります。返されるのは、コールバックが返した値そのものをキーとする Map です。
const byTotal = Map.groupBy(orders, (order) => order.total);
console.log([...byTotal.keys()]); // [40, 15, 60, 22]
console.log(typeof [...byTotal.keys()][0]); // "number"
console.log(byTotal.get(40).length); // 1
キーは数値のまま挿入順で返り、.get() で読み取ります。MDN の Map.groupBy ページにある例はオブジェクトの同一性でグループ化しており、これはオブジェクトリテラルでは到底扱えないケースです。内容が同一でも異なる 2 つのオブジェクトは、Map のキーとしては別物のままです。
Object.groupBy | Map.groupBy | |
|---|---|---|
| キーの型 | 文字列またはシンボルに強制変換 | 任意の値をそのまま保持 |
| 戻り値の型 | null プロトタイプのオブジェクト | Map |
| グループの読み取り | result.shipped | result.get(key) |
| キーの列挙順 | 挿入順。ただし整数風のキーは昇順にソートされる | 挿入順 |
なぜ Object.groupBy は数値キーを文字列に変えてしまうのか
Object.groupBy で真偽値や数値によるグループ化を行うと、元の true や 40 ではなく、文字列キー "true" や "40" が生成されます。Map.groupBy は元の値を Map のキーとして保持します。コールバックが返したものは最終的にプロパティキーにならなければならないため、すでに文字列やシンボルでないものは、渡される過程で文字列に変換されます。これは通常のオブジェクトの挙動ですが、真偽値をキーとする結果を期待している人は今なおここでつまずきます。
const byPaid = Object.groupBy(orders, (order) => order.status === "shipped");
console.log(Object.keys(byPaid)); // ["true", "false"]
console.log(typeof Object.keys(byPaid)[0]); // "string"
console.log(byPaid[true] === byPaid["true"]); // true (lookup coerces too)
const byTotalObj = Object.groupBy(orders, (order) => order.total);
console.log(Object.keys(byTotalObj)); // ["15", "22", "40", "60"]
数値のケースでは 2 つのことが起きています。合計値が文字列になったこと、そして挿入順ではなく昇順にソートされて返ってきたことです。これは 整数風のプロパティキーが他の文字列キーより先に昇順で列挙される ためです。元の並び順を期待して結果を反復処理するコードは、何のエラーも投げずに、間違った順序でグループを描画してしまいます。
グループ化のバグに関するセッションリプレイでは、まさにこの形がよく見られます。カテゴリのヘッダーが “Shipped” ではなく true と表示されていたり、データがソートされていないのにバケットがソートされて現れたり。コンソールはクリーンで、{ true: [...] } はキーが真偽値でも文字列でも同じように出力されるためログ上のデータ形状は正しく見えます。レンダリングされた UI をコードパスと並べて見て初めて、この型強制が明白になるのです。
なぜ Object.groupBy の結果に対して hasOwnProperty が例外を投げるのか
Object.groupBy が返すオブジェクトは null プロトタイプを持つため、result.hasOwnProperty("shipped") は TypeError を投げます。Object.hasOwn(result, "shipped") を使うか、後続のコードが通常のオブジェクトを期待しているならスプレッド構文で結果をコピーしてください。MDN は戻り値を null プロトタイプオブジェクト として文書化しており、これは Object.prototype の何一つとして到達できないことを意味します。hasOwnProperty も toString も valueOf もありません。
const byStatus = Object.groupBy(orders, (o) => o.status);
byStatus.hasOwnProperty("shipped");
// TypeError: byStatus.hasOwnProperty is not a function
Object.hasOwn(byStatus, "shipped"); // true
"shipped" in byStatus; // true
Object.keys(byStatus); // ["shipped", "pending", "refunded"]
JSON.stringify(byStatus); // works normally
const plain = { ...byStatus }; // ordinary object with Object.prototype
plain.hasOwnProperty("shipped"); // true
MDN は hasOwnProperty の現代的な代替として Object.hasOwn を挙げており、2022 年 3 月以降 Baseline Widely available となっているため、そのまま利用できます。Object.keys、Object.entries、in 演算子、JSON.stringify、スプレッドはいずれもプロトタイプチェーンに依存しないため、null プロトタイプの結果に対しても動作します。問題が表面化するのは、多くの場合ユーティリティライブラリやテンプレートエンジンの奥深くにあるヘルパーが、オブジェクト自身のメソッドを呼び出したときだけです。あるグループが黙って描画されない、あるいはレンダリングループの内部から TypeError が投げられる、というのが典型的な症状です。
Object.groupBy と Map.groupBy をサポートしているブラウザは
Object.groupBy と Map.groupBy は同じサポート状況にあります。どちらも MDN 上で Baseline Widely available とされ、2024 年 3 月以降すべての主要ブラウザで利用可能なので、現在のブラウザターゲットに対してはどちらもポリフィルを必要としません。両者の選択はキー次第です。グループ名が自然に文字列となる場合(ステータス、カテゴリ、チーム名など)は、Object.groupBy がドット記法でインデックスできる普通のオブジェクトのようなものを返してくれます。キーがオブジェクト、Date、算術演算を行う数値、あるいは真偽値として比較したい真偽値である場合は、Map.groupBy がそれをそのまま保持し、上記 2 つの落とし穴を回避します。
アキュムレータを置き換える
??= [] の行を含む reduce は、グループ化キーが文字列であり、かつ後続のコードが結果に対して hasOwnProperty を呼ばない限り、1 行の Object.groupBy 呼び出しに置き換えられます。キーがそれ以外の場合は Map.groupBy を使い、.get() でグループを読み取りましょう。いずれにしても、テストすべきコードとして残るのはグループの所属を決めるロジックだけです。
FAQ
Object.groupBy は TypeScript で動作しますか。どんな型を返しますか?
はい。TypeScript 5.4 で Object.groupBy と Map.groupBy の型宣言が追加され、tsconfig の target または lib に es2024 か esnext が含まれている場合に利用できます。それより古い lib 設定では、groupBy が ObjectConstructor に存在しないと報告されます。Object.groupBy は Partial Record として型付けされているため、すべてのグループは undefined の可能性があり、インデックスアクセスする前にチェックが必要です。Map.groupBy はキー型から要素の配列への Map として型付けされます。
Object.groupBy のコールバックが undefined や null を返すとどうなりますか?
Object.groupBy はすべてのコールバック結果をプロパティキーに変換するため、その要素は文字列 'undefined' または 'null' をキーとするグループに入ります。何もスキップされず、エラーも投げられないので、フィールドの欠落が黙って余分なグループを生み出します。Map.groupBy は実際の undefined や null の値を Map のキーとして保持します。これらの要素を除外したい場合は、先に配列をフィルタリングするか、'unknown' などのフォールバックキーを返してください。
Object.groupBy と Lodash の groupBy の違いは何ですか?
Lodash の groupBy は Object.prototype を継承する通常のオブジェクトを返すため hasOwnProperty が使えますが、Object.groupBy は null プロトタイプのオブジェクトを返します。Lodash は groupBy(orders, 'status') のようなプロパティ名のショートハンドを受け付け、関数の iteratee を値という 1 つの引数で呼び出します。一方 Object.groupBy は関数を必須とし、要素とそのインデックスを渡します。また Lodash は入力としてプレーンオブジェクトも受け付けますが、Object.groupBy は任意の iterable を受け付けます。どちらもキーを文字列に強制変換します。
Object.groupBy で複数のフィールドによるグループ化を行うには?
コールバックから 1 つの複合文字列を返します。例えば status とサイズのバケットを区切り文字で連結して 'shipped:large' のようなキーを作ります。Object.groupBy に複数キーのモードはなく、各要素はちょうど 1 つのプロパティキーを受け取ります。フィールドを別々に扱いたい場合は呼び出しをネストします。まず status でグループ化し、次に各グループの配列に対して 2 つ目のフィールドで Object.groupBy を実行すれば、result.shipped.large として読み取れる 2 階層の構造が得られます。
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k