12k
All articles

JavaScriptの新しいSetメソッド実践ガイド

JavaScriptのSet新メソッドを解説。union、intersection、difference、symmetricDifference、部分集合判定に加え、Map対応とブラウザ対応を紹介。

OpenReplay Team
OpenReplay Team
JavaScriptの新しいSetメソッド実践ガイド

JavaScriptのSetオブジェクトに7つの新しいインスタンスメソッド — union()intersection()difference()symmetricDifference()isSubsetOf()isSupersetOf()isDisjointFrom() — が追加され、2024年6月11日にBaseline Newly Availableとなりました。これらのメソッドは、フロントエンド開発者がコレクションを比較するために長年手書きしてきたArray.filter() + Array.includes()パターンを置き換えるものです。ネストされた配列スキャンではなく、集合ベースのルックアップを使用することで、より効率的な処理を実現します。ES2015のSet API(addhasdelete、イテレーション)をすでに知っている方を対象に、このガイドではアナウンス記事が省略していた部分を解説します。具体的には、Mapを渡せるようにするSet-likeな引数プロトコル、オブジェクト比較を静かに壊す参照等価性の落とし穴、すぐに使えるフロントエンドパターン、そして正直なブラウザサポート表を取り上げます。

重要なポイント

  • 7つの新しいSetメソッドはすべて2024年6月11日にBaseline Newly Availableとなり、Chrome 122、Edge 122、Firefox 127、Safari 17、およびNode.js 22.0.0でリリースされています。現在のターゲット環境ではポリフィルは不要です。
  • これらのメソッドの引数はSetインスタンスである必要はありません。数値型のsizeプロパティ、呼び出し可能なhasメソッド、呼び出し可能なkeysメソッドを持つオブジェクトであれば十分です。つまりMapを直接渡すことができます。
  • Setは値を参照で比較するため、new Set([{id:1}]).intersection(new Set([{id:1}]))は空のセットを返します。代わりに安定したプリミティブIDのSetを使って積集合を求めてください。
  • difference()は可換ではありません。symmetricDifference()はレシーバー側のみの要素を先にレシーバーの順序で返します。
  • [...a].filter(x => b.includes(x))a.intersection(b)に置き換えることで、O(n²)の配列パターンから、仕様が規定する平均的なサブ線形Setアクセスを前提として小さい方のセットに応じてスケールするパターンへと移行できます。

7つのメソッドの概要

新しいメソッドは2つのグループに分かれます。新しいSetを返す4つのメソッドと、真偽値を返す3つのメソッドです。この区別が最も実用的なメンタルモデルです。コレクションを構築しているのか、Yes/Noの質問をしているのかが一目でわかります。

新しいSetを返す操作:

  • union(other) — このセットまたはotherに含まれる要素(SQLのFULL OUTER JOINに相当)
  • intersection(other)両方のセットに含まれる要素(INNER JOINに相当)
  • difference(other) — このセットに含まれ、otherには含まれない要素(LEFT JOINに相当)
  • symmetricDifference(other)どちらか一方のセットに含まれ、両方には含まれない要素

真偽値を返す述語メソッド:

これらを追加したプロポーザルはTC39のSet methodsプロポーザルです。Stage 4に到達し、現在は非アクティブとなり、ECMAScript仕様に追加され、ECMAScript 2025の一部となっています。

セットを返すメソッドを実際のフロントエンドコードで使う

セットを返す4つのメソッド — union()intersection()difference()symmetricDifference() — はいずれも、どちらの入力も変更せずに新しいSetを返します。また、フロントエンド開発者がすでに手作業で行っているタスクにそれぞれ対応しています。

union(): フィーチャーフラグのオーバーライドとデフォルトをマージする

union()は、両方のセットのすべての要素を含む新しいセットを、重複を除いて返します。

const defaults = new Set(["new-dashboard", "dark-mode"]);
const overrides = new Set(["dark-mode", "beta-search"]);

const enabled = defaults.union(overrides);
// Set(3) { "new-dashboard", "dark-mode", "beta-search" }

従来の書き方はnew Set([...defaults, ...overrides])でした。union()は意図を直接表現できます。有効なフィーチャーフラグのベースセットをユーザーごと、または環境ごとのオーバーライドとマージする場合、union()は1回の呼び出しで有効なフラグセットを返します。

intersection(): ユーザーが実際にアクセスできるルートを見つける

intersection()は、両方のセットに存在する要素のみを含む新しいセットを返します。

const requiredForRoute = new Set<string>(["billing:read", "billing:write"]);
const userPermissions = new Set<string>(["billing:read", "users:read"]);

const satisfied = requiredForRoute.intersection(userPermissions);
// Set(1) { "billing:read" }

ルートのアクセス制御では、ルートが必要とするパーミッションセットとユーザーが持つパーミッションセットの積集合を求めます。結果のサイズを必要なセットと比較することで、アクセスが部分的か完全かを判断できます。

difference(): 複数選択における前後の選択状態の差分を求める

difference()は、このセットに含まれ、otherには含まれない要素を持つ新しいセットを返します。これは可換ではありません

const prevSelected = new Set<string>(["a", "b", "c"]);
const nextSelected = new Set<string>(["b", "c", "d"]);

const added = nextSelected.difference(prevSelected);   // Set(1) { "d" }
const removed = prevSelected.difference(nextSelected); // Set(1) { "a" }

複数選択コンポーネントでは、nextSelected.difference(prevSelected)で追加されたアイテムを、prevSelected.difference(nextSelected)で削除されたアイテムを取得できます。ソートやネストされたループが必要だったパターンを2つのセット操作で置き換えられます。a.difference(b)aに存在しbに存在しない要素を返し、b.difference(a)はその逆を返します。引数の順序が演算の内容を決定します。

symmetricDifference(): 2つのスナップショット間で変更されたキーをハイライトする

symmetricDifference()は、どちらか一方のセットに含まれ、両方には含まれない要素を持つ新しいセットを返します。

const before = new Set<string>(["name", "email", "phone"]);
const after = new Set<string>(["name", "email", "address"]);

const changedKeys = before.symmetricDifference(after);
// Set(2) { "phone", "address" }

状態オブジェクトの2つのスナップショット間で出現または消滅したキーをハイライトするには、それぞれのキーセットの対称差を求めます。アナウンス記事が省略している詳細があります。イテレーションの順序はレシーバーに依存します。ECMA-262 2025によると、symmetricDifference()はレシーバーのみの要素をレシーバーの順序で返し、その後にotherのみの要素をother.keys()の順序で返します。要素のセット自体はどちら側から呼び出しても同一ですが、順序は異なります。

認可チェックに真偽値述語を使う

3つの述語メソッド — isSubsetOf()isSupersetOf()isDisjointFrom() — はいずれも真偽値を返し、それぞれが一般的な認可チェックや入力バリデーションに対応しています。

isSupersetOf(): 必要なスコープがすべて揃っているか確認する

isSupersetOf()は、このセットが指定されたセットのすべての要素を含む場合にtrueを返します。

const grantedScopes = new Set<string>(["read", "write", "delete"]);
const requiredScopes = new Set<string>(["read", "write"]);

const hasAllRequiredScopes = grantedScopes.isSupersetOf(requiredScopes);
// true

ユーザーに付与されたOAuthスコープがある操作に必要なすべてのスコープをカバーしているかどうかを確認するには、grantedScopes.isSupersetOf(requiredScopes)を1回呼び出すだけです。これは[...requiredScopes].every(s => grantedScopes.has(s))と等価ですが、集合の関係として表現されています。

isSubsetOf(): タグリストが完全にサポートされているか検証する

isSubsetOf()は、このセットのすべての要素が指定されたセットに含まれる場合にtrueを返します。

const supportedTags = new Set<string>(["sale", "new", "featured", "clearance"]);
const requestedTags = new Set<string>(["sale", "new"]);

const allSupported = requestedTags.isSubsetOf(supportedTags);
// true

呼び出し元がタグやフィルターのリストを渡す場合、requestedTags.isSubsetOf(supportedTags)を使うと、クエリを実行する前にすべてのタグが認識されているかどうかを確認できます。

isDisjointFrom(): 競合するモディファイアキーを検出する

isDisjointFrom()は、2つのセットが共通の要素を持たない場合にtrueを返します。

const pressedKeys = new Set<string>(["Meta", "Shift"]);
const conflictingModifiers = new Set<string>(["Control", "Alt"]);

const noConflict = pressedKeys.isDisjointFrom(conflictingModifiers);
// true

キーボードショートカットの処理では、isDisjointFrom()を使うことで、アクションを実行する前に競合するモディファイアキーが押されていないことを確認できます。

Set-likeプロトコル: 引数はSetである必要はない

これらのメソッドの引数はSetインスタンスである必要はありません。数値型のsizeプロパティ、呼び出し可能なhasメソッド、呼び出し可能なkeysメソッドを持つオブジェクトであれば十分です。Mapはこの要件をネイティブに満たしているため、mySet.intersection(myMap)は有効であり、マップのキーに対してチェックが行われます。すべてのアナウンス記事では引数を「別のセット」と説明していますが、これは技術的に不完全です。

このプロトコルはECMA-262仕様のGetSetRecordに定義されており、MDNもSet-likeオブジェクトの要件を直接文書化しています

const map = new Map([
  ["a", 1],
  ["b", 2],
]);
const set = new Set(["a", "c"]);

set.intersection(map);
// Set(1) { "a" }  — マップのキーに対してチェックされる

Node v22.16.0で確認済みですが、set.intersection(map)Set { 'a' }を返します。これは"a"がセットの要素の中でマップのキーにも存在する唯一の要素だからです。この動作は相互運用性において重要です。new Set(map.keys())という中間的なセットを構築せずにSetMapのキーと交差させることができます。また、sizehaskeysを公開するイミュータブルコレクションライブラリやカスタムインデックスオブジェクトも、変換なしにこれらのメソッドで使用できます。

オブジェクト同一性の落とし穴

Setは値を構造ではなく参照で比較します。MDNの値等価性のドキュメントでもこれが確認されています。実際の影響として、セットにオブジェクトが含まれる場合に落とし穴があります。

new Set([{ id: 1 }]).intersection(new Set([{ id: 1 }]));
// Set(0) {}

参照等価性が落とし穴です。2つの{id: 1}オブジェクトは見た目は同一ですが、異なる参照であるため、空のセットが返されます。解決策は、安定したプリミティブIDのSetで積集合を求め、その後ルックアップマップから元のオブジェクトを再構築することです。

type User = { id: number; name: string };

const prev: User[] = [{ id: 1, name: "Ada" }, { id: 2, name: "Lin" }];
const next: User[] = [{ id: 2, name: "Lin" }, { id: 3, name: "Mo" }];

const byId = new Map(next.map((u) => [u.id, u]));

const prevIds = new Set(prev.map((u) => u.id));
const nextIds = new Set(next.map((u) => u.id));

const addedIds = nextIds.difference(prevIds); // Set(1) { 3 }
const added = [...addedIds].map((id) => byId.get(id)!);
// [{ id: 3, name: "Mo" }]

このような参照等価性のバグはスタックトレースを残しません。セット操作はエラーなく完了し、空のセットを返すだけで、UIは単に更新されません。複数選択が変わらない、パーミッションバッジがクリアされない、差分ビューに変更が表示されない、といった症状が現れます。例外が発生しないため、エラー監視ツールにも検出されません。セッションリプレイは、このような無音の失敗バグを可視化するのに有効な手法です。ユーザーの操作、処理の完了、そして何も反応しないインターフェースを実際に確認できます。

パフォーマンス: filter + includesより優れている理由

[...a].filter(x => b.includes(x))a.intersection(b)に置き換えることは、可読性の向上だけにとどまりません。Array.includes()はO(n)であるため、フィルターパターンはn要素の2つのコレクションに対してO(n²)になります。一方、intersection()は仕様が要求する平均的なサブ線形Setアクセスを前提として、2つのセットの小さい方に応じてスケールします。ECMA-262のSetオブジェクトのセクションでは、Setのアクセスが平均的にサブ線形で実行されることを義務付けています(O(1)を保証するわけではありません)。また、MDNのintersection()リファレンスでは、小さい方のセットに応じたスケーリング動作が説明されています。

操作配列パターン計算量ネイティブSetメソッド計算量
積集合[...a].filter(x => b.includes(x))O(n²)a.intersection(b)小さい方のセットに応じてスケール、平均サブ線形アクセス
和集合new Set([...a, ...b])O(n + m)a.union(b)O(n + m)
差集合[...a].filter(x => !b.includes(x))O(n²)a.difference(b)aに応じてスケール、平均サブ線形アクセス

コレクションのサイズが大きくなるほど差は広がります。要素数が少ない場合は無視できる差ですが、配列パターンは二次的に劣化するのに対し、ネイティブメソッドはそうなりません。

移行レシピ

既存の配列比較コードを新しいメソッドに直接マッピングできます。以下の3つの置き換えが実際のユースケースの大部分をカバーします。

旧パターン新しいメソッド
[...a].filter(x => b.includes(x))a.intersection(b)
[...new Set([...a, ...b])]a.union(b)Setを返す)
[...a].filter(x => !b.includes(x))a.difference(b)

データが配列に格納されている場合は、各側を一度new Set(...)でラップし、操作を実行し、下流のAPIが必要とする場合は配列にスプレッドして戻します。

const a = ["x", "y", "z"];
const b = ["y", "z", "w"];

const common = [...new Set(a).intersection(new Set(b))];
// ["y", "z"]

Setへの変換自体はO(n)ですが、この往復変換によってO(n²)のfilter + includesパターンで必要なネストされたスキャンを回避でき、コレクションが大きくなるほどスケーラビリティが向上します。

ブラウザサポートとポリフィル

7つのSetメソッドはすべて2024年6月11日にBaseline Newly Availableとなり、Chrome 122(2024年2月20日)、Edge 122(2024年2月23日)、Firefox 127(2024年6月11日)、Safari 17(2023年9月18日)でリリースされており、現在のブラウザターゲットではポリフィルは不要です。また、V8 12.4を搭載したNode.js 22.0.0(2024年4月24日)でも利用可能です。

環境バージョンリリース日
Chrome1222024年2月20日
Edge1222024年2月23日
Firefox1272024年6月11日
Safari172023年9月18日
Node.js22.0.0 (V8 12.4)2024年4月24日

古いターゲット向けには、core-jsライブラリとes-shimsプロジェクトが仕様準拠のポリフィルを提供しています。現在のエバーグリーンブラウザとNode.js 22以降のみをサポートする場合は、ポリフィルを完全に省略できます。

次のステップ

コードベースで重複排除されたデータに対するfilter + includesfilter + !includesのパターンを探してみてください。それらがintersection()difference()への直接の移行候補です。オブジェクトを保持するコレクションを移行する前に、参照等価性の落とし穴を避けるため、安定したプリミティブIDのSetに切り替えてください。また、sizehaskeysを公開するMap、イミュータブルコレクション、カスタムインデックスはいずれも引数として直接渡せることを覚えておいてください。これらのメソッドはBaselineに準拠しており、セマンティクスは仕様として安定しており、置き換え対象の配列パターンは見た目ほど安価ではありませんでした。

よくある質問

MapをSet.intersection()に直接渡せますか?それとも最初に変換する必要がありますか?

Mapを直接渡すことができます。新しいSetメソッドはSet-likeオブジェクトを受け入れます。ECMA-262仕様では、数値型のsizeプロパティ、呼び出し可能なhasメソッド、呼び出し可能なkeysメソッドを持つオブジェクトとして定義されています。Mapはこれをネイティブに満たしているため、mySet.intersection(myMap)はnew Set(map.keys())という中間的なセットを構築せずにマップのキーに対してチェックを行います。同じ3つのメンバーを公開するイミュータブルコレクションライブラリのセットやカスタムインデックスオブジェクトも、変換なしに使用できます。

両方のセットに同一のオブジェクトが含まれているのに、Setの積集合が空のセットを返すのはなぜですか?

Setは値を構造ではなく参照で比較するからです。new Set([{id:1}]).intersection(new Set([{id:1}]))は、2つの{id:1}オブジェクトが見た目は同一でも異なる参照であるため、空のセットを返します(Node v22.16.0で確認済み)。解決策は、安定したプリミティブIDのSetを構築し、それらに対して操作を実行し、IDをキーとするルックアップMapからマッチしたオブジェクトを再構築することです。

difference()とsymmetricDifference()の違いは何ですか?

difference()は方向性があり可換ではありません。a.difference(b)はaに存在しbに存在しない要素を返し、b.difference(a)はその逆を返します。symmetricDifference()はどちらか一方のセットに含まれ両方には含まれない要素を返し、内容については順序に依存しません。ただし、イテレーションの順序は異なります。symmetricDifference()はレシーバーのみの要素をレシーバーの順序で返し、その後にotherのみの要素をother.keys()の順序で返します。そのため、結果のセット自体はどちら側から呼び出しても同一ですが、順序は異なります。

本番環境で新しいSetメソッドのポリフィルはまだ必要ですか?

現在のブラウザターゲットでは不要です。7つのメソッドはすべて2024年6月11日にBaseline Newly Availableとなり、Chrome 122、Edge 122、Firefox 127、Safari 17、およびV8 12.4を搭載したNode.js 22.0.0でリリースされています。現在のエバーグリーンブラウザとNode.js 22以降のみをサポートする場合は、ポリフィルを完全に省略できます。古いターゲット向けには、core-jsライブラリとes-shimsプロジェクトが仕様準拠のポリフィルを選択的に提供しています。

Open-source session replay

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

We use cookies to improve your experience. By using our site, you accept cookies.