皆さん、こんにちは! デジスマチームの小島(@jiko_21)です。
このブログはデジスマチームブログリレーの 2 日目の記事です。
JavaScript の進化は止まりませんね! 毎年、新しい仕様が TC39(ECMAScript の標準化委員会)にて議論され追加されています。
今回は、最近 TC39 に追加された、proposal-compositeについてご紹介します。これは、JavaScript におけるObjectの比較という、長年の課題を解決する可能性を秘めた興味深い提案です。
proposal-composite について
proposal-compositeは、一言で言うと「明確に値が同じなら同じと判定できるObject(Composite)を JavaScript のビルトイン機能として提供しよう」というプロポーザルです。
これまでの JavaScript では、MapのkeyやSetでObjectを扱おうとすると、たとえ見た目が同じObjectでも、内部的には異なる参照として扱われてしまうという問題がありました。
例えば、
const position1 = Object.freeze({ x: 1, y: 4 }); const position2 = Object.freeze({ x: 1, y: 4 }); const positions = new Set([position1, position2]); console.log(positions.size); // 2 となる。期待値は 1 const map = new Map(); map.set(position1, 1); console.log(map.get(position2)); // undefined となる。期待値は 1
となります。
このように、Setでは重複が排除されず、Mapでは別のkeyとして扱われてしまいます。これは、JavaScript が Object の比較に「同一の参照であるか」をチェックするSameValueZeroという戦略を用いているためです。
SameValueZero による評価戦略
SameValueZero とは?
SameValueZeroは、ECMAScript の仕様書で定義されている抽象的な比較操作です。簡単に言うと、「2 つの値が同じであるかどうか」を判定するためのものです。
この比較は、Mapのkeyが既に存在するかどうかをチェックしたり、Setに要素を追加する際に重複を排除したりする際に、JavaScript の内部で密かに利用されています。異なるObject参照であっても内容が同じ場合にSetで重複が排除されないのは、このSameValueZeroの比較ルールが背景にあります。
SameValueZero の動作原理(疑似コードで理解する)
それでは、SameValueZeroがどのように動作するのか、ECMAScript の仕様書に準拠した疑似コードを見てみましょう。
function SameValueZero(x, y) { // 同じ型じゃないならfalse if (!SameType(x, y)) return false; // 数字なら数字同士比較 if (typeof x === "number") return Number.SameValueZero(x, y); return SameValueNonNumber(x, y); }
この疑似コードからわかるように、SameValueZeroはまず 2 つの値の型が同じかどうかをチェックします。型が異なる場合は即座にfalseを返します。もし型が同じで、かつ数値であれば、数値専用の比較ロジックであるNumber::sameValueZeroが呼び出されます。これは、NaN同士がtrueと評価されたり、+0と-0が区別されなかったりする数値特有の比較します。それ以外の型であれば、SameValueNonNumberという別の抽象操作に処理が委ねられます。
では、SameValueNonNumberはどのように動作するのでしょうか? こちらも疑似コードを見てみましょう。
function SameValueNonNumber(x, y) { assert(SameType(x, y) === true); // ここに到達する時点で型は同じ if (x === null || x === undefined) return true; if (x is BigInt) return BigInt.equal(x, y); // 文字列なら文字列長とそれぞれの位置の文字をチェック if (x is string) return x === y; if (x is boolean) return x === y; // ECMAScriptの値なら同じものを指してるか確認 if (x is y) return true; // Objectの場合、同じ参照かどうかをチェック return false; }
このSameValueNonNumberの疑似コードから、次の重要なポイントが読み取れます。
- プリミティブ値:
null、undefined、BigInt、文字列、真偽値の場合、それらの値が単純に等しいかどうかでtrueを返します。例えば、文字列の"hello"と"hello"はtrueになります。 - ECMAScript Object: 最も重要なのがこの部分です。
if (x is y) return true;という行は、xとyが同じ Object を参照しているか(つまり、メモリ上の同じ場所を指しているか)をチェックしています。たとえ Object の中身(プロパティの値)が全く同じであっても、異なる Object であればfalseとなります。
これが原因でMapのkeyやSetの要素として Object を使うと、同じ内容の Object でも異なる参照として扱われてしまうのです。
proposal-composite がどのようなアプローチで解決を目指すのか?
そこで、Composite では各フィールドに対して、次のような比較戦略を採用しています。
- SameValueZero による比較: 各フィールドの値が、
SameValueZeroを用いて判定します。これにより、プリミティブな値(数値、文字列、真偽値など)はそのまま比較され、Object の参照が同じであればtrueと評価されます。 - 再帰的な Composite 比較: もし比較対象のフィールドの両方が Composite である場合、Composite 自身が持つ比較ルールを再帰的に適用してチェックします。これにより、ネストされた Composite の中身まで深く比較することが可能になります。
ここで、循環参照を保持する場合、つまり
const a = Composite({}); a.b = a; // { b: { b: ... } }
のような場合、再帰的に比較すると無限ループに陥ってしまいますが、Composite は Immutable な特性を持つため、循環参照を持つ Object は作成できません。 これにより、無限ループのリスクを回避しています。
Composite の多岐にわたる特徴
Compositeは単なるObjectのラッパーではありません。JavaScript の柔軟なデータ構造と、厳格なデータ管理の両立を目指して設計されており、その特性は多岐にわたります。
Compositeは Object として扱われますが、通常の Object とは一線を画す点があります。Composite(...)のように関数形式で呼び出すと、引数に渡された値(Object)から新しいComposite Objectが生成されます。- 生成元の値に対する振る舞いも明確です。
Compositeの引数として渡された Object は、Compositeが生成された後も一切変更されません。 Compositeが受け入れる引数はObject のみに限定されています。- 生成された
Composite値は、そのすべてがObject.isFrozenがtrueを返す状態になります。 Composite内部のデータはすべて公開(exposed)されます。これにより、内部構造に簡単にアクセスできますが、不変性によって安全性が保たれています。- プロパティの
keyは、Compositeの作成時の順序を維持します。
なぜこのプロポーザルが面白いのか?
このプロポーザルが面白いのは、単に比較戦略自体をアップデートすることだけではなく、次の 2 つのプロポーザルとも密接に関係していることです。
- proposal-record-tuple
- proposal-iterator-unique
proposa-record-tuple との関係
proposal-record-tupleは、JavaScript に Record や Tuple という新しいデータ構造を導入する提案です。これらは、Immutable なデータ構造であり、特にデータの整合性を保ちながら扱うことができます。
Record は Object のようにkeyと値のペアを持ちますが、Immutable であるため、一度作成された Record は変更できません。Tuple は配列のように順序付けられた値の集合ですが、こちらも Immutable です。
ここまで聞くと、Compositeと似たような特性を持つように思えますが、Compositeはこれらのデータ構造をさらに拡張し、より柔軟な比較戦略を提供します。
まず、Composite は Object ですが Record や Tuple は primitive なデータ構造です。そのため、==や===での比較が可能です。
しかしながら、Compositeは内部的に Immutable な特性を持ちつつ、Object のプロパティを自由に定義できる点で、Record や Tuple とは異なります。
Record はプロパティとしてプリミティブ型の値しか持つことができません。そのため、プロパティとして Object 型の値を持つことができないというデメリットがあります。
こうした背景もあってか、tc39 の stage 1 でしたが、withdrawal、つまり取り下げとなりました。
proposal-iterator-unique との関係
proposal-iterator-uniqueは、コレクション内の値を一意にするためのイテレーターを提供する提案です。これは、特定のコレクション(例えば、配列やセット)から重複を排除し、一意な値のみを取得するための便利な方法を提供します。
const users = ["yamamoto", "suzuki", "tanaka", "yamamoto"]; users.uniqBy(); // または const users = [ { name: "yamamoto" }, { name: "suzuki" }, { name: "tanaka" }, { name: "yamamoto" }, ]; users.uniqBy((user) => user.name);
uniqBy メソッド内では sameValueZero を用いて、重複を排除していますが、Compositeを利用することで、よりシンプルに記述できます。
const users = Composite([ { firstName: "yamamoto", lastName: "taro" }, { firstName: "suzuki", lastName: "jiro" }, { firstName: "tanaka", lastName: "saburo" }, { firstName: "yamamoto", lastName: "taro" }, ]); users.includes( Composite({ firstName: user.firstName, lastName: user.lastName }) ); // true
どのような場面で使えるか
今まで、Composite はMapやSetなどに関係する、という話をしてきましたが、実はArray.proptotype配下のメソッドにも関係してきており、非常に便利です。
実際に polyfil で公開されているものには以下があります。
- includes
- indexOf
- lastIndexOf
実際に includes を使って同じ内容の Object があるか調べる場合について見ていきます。
同じ内容の Object があるか調べたいケース
今まで、配列内に同じ内容の Object、つまり、中のプロパティが同じ Object があるかどうかは、find メソッドを用いて、すべてのフィールドの値を比較する必要がありました。 今回の composite を用いることで、次のように簡潔に書くことができるようになります。
const positions = [Composite({ x: 1, y: 4 }), Composite({ x: 2, y: 4 })]; const found = positions.includes(Composite({ x: 1, y: 4 })); console.log(found); // Composite { x: 1, y: 4 }
注意点
ここまで見ると、Compositeは非常に便利な機能のように思えますが、いくつか注意点もあります。
挙動の変更
Compositeにより比較挙動が変わるため、ブラウザやランタイムの対応状況によってはブラウザごとにMapやSetの挙動が変わってしまいます。
ちゃんとPolyfillを導入すればよいのですが、内部実装に依存する挙動のため、ちゃんと挙動を把握して導入できるかと言われると難しいかもしれません。
一部メソッドでは利用できない可能性
Compositeにより Map のkeyや Set の挙動が変わるかと思われますが、一部のメソッドでは従来通りの動きをする可能性があります。
例えば、Map.groupByメソッドはコールバック内でkeyを計算し、その結果ごとにグルーピングを行いますが、Compositeをkeyとして使用した場合、同じ内容の Object が異なる参照として扱われ、適切にグルーピングされない可能性があります。
これは、Map を生成する前段階で GroupByという処理を実行しており、そこでkeyのグルーピングが行われるためです。
2025年7月22日現在で proposal の issue には該当する議論がないのでもしかしたら追加されるかもしれません。
まとめ
まだ Stage 1 の段階ではありますが、このプロポーザルが正式に採用されれば、今までの JavaScript の Object 比較の常識が大きく変わるかもしれません。
特に、今までプリミティブな値のみを前提としてた操作(Array.includesやSetなど)にてObjectを利用できるようになるので、複雑なデータ構造がよりシンプルに表現できるようになるかもしれません。
今後の動向に注目しつつ、実際に使えるようになる日を楽しみに待ちたいと思います。
We are Hiring!
エムスリーでは、フロントエンド・バックエンドに関わらず、新しい技術に興味のあるエンジニアを募集しています。新卒もお待ちしております!