Proposal: Comparisons
June 11, 2026 · View on GitHub
Champions:
- Jacob Smith (@JakobJingleheimer)
- Richard Gibson (@gibson042)
Authors:
- Jacob Smith (@JakobJingleheimer)
- Ruben Bridgewater (@BridgeAR)
Stage
Current: 1
The Problem
Determine whether and/or how A and B deviate from each other—a very common need that is currently solved only for very narrow cases (primitives, and to some extent JSON.stringifyable data structures). This issue has 2 parts: (deep) equality and details.
Motivation
Facilitate making decisions about deep equality that users are often unaware of.
Walking an object is difficult and not fun; determining equality can be difficult, requiring an enormous amount of specific knowledge that the vast majority of users don’t have. These complexities create significant barriers and risks to users.
Equality (currently)
Primitives are mostly trivial: Object.is provides the strictest comparison (SameValue), and === (IsStrictlyEqual) is only slightly looser, failing to differentiate oppositely-signed zeros and failing to equate NaNs.
'foo' === 'bar'
1 === 2
true === false
But what "similar" means is not straightforward for objects. A human considers these "equal" (but the language does not):
const a = { a: 1 };
const b = { a: 1 };
const a = [1];
const b = [1];
const a = new String('foo');
const b = new String('foo');
There is some variation in the ecosystem regarding the nuances of comparing objects.
Even more important is the details: Merely knowing that A and B differ is almost useless without knowing specifically how they differ.
Annoying:
if (A !== B) throw new Error('A does not equal B');
// Error: A does not equal B
Better
if (A !== B) throw new Error(`${A} does not equal ${B}`);
// Error: 1 does not equal 2
But brittle
if (A !== B) throw new Error(`${A} does not equal ${B}`);
// Error: [object Object] does not equal [object Object]
Use-cases
Production: Delta for HTTP PATCH
Many client-side apps manipulate data, sometimes very large data. That could be via a <form>, a text editor, or something else. Since the before and after are known, only the delta is needed (sent via http patch).
<Form onSubmit={submitPatch}>
function submitPatch(prev, next) {
const patch = composeDelta(prev, next);
fetch(…, {
body: JSON.stringify(patch),
method: 'PATCH',
});
}
Production: Logging
log('bad data', compare(initiallyGood, nowBad));
Production: State management
In frameworks such as React, state is often based on derived data in which updates don't always include a material change:
setState((prev) => ({
...prev,
x: x / 2,
}));
This is currently left up to the user to guard against because it's too difficult and expensive to for the library to check.
React tried to get this before (see Prior Art → Shallow Equal).
Production: Validation
Input from an uncontrolled origin:
try {
assert.is(
total += value,
NaN,
);
} catch (err) {
toast(…);
}
Production: Virtual DOM
{items.map(({ id, label }) => (
<button onClick={() => remove(id)}>
{label}
</button>
))}
Testing
assert.equal(
{ foo: 1 },
{ foo: 1, bar: 2 },
);
Explicitly out of scope
- This is not a test runner (
describe,it, etc). - This is not a test utility suite (
mock,stub, etc).
Solution (sketches)
Compare
A function to deeply compare values.
function compare(
expected: any,
actual: any,
options: CompareOptions,
): (true | Iterator<Deviation>) | undefined;
CompareOptions
type CompareOptions = {
mode?:
| 'fast' // (default) return => boolean
| 'full' // return => Iterator<Deviation>
,
reasons?: Partial<{
constructor: boolean, // default: `false`
descriptors: boolean, // default: `false`
promise: 'ref' | 'value', // default: `'value'`
prototype: boolean, // default: `false`
weak: 'ref' | 'value', // default: `'value'`
}>,
};
- mode
- How the comparison reports the result
- mode
fast - Return
truewhen deviation(s) exist orundefinedwhen no deviation exist. - mode
full - Return an
IteratorofDeviationswith all deviations, orundefinedwhen no deviation exist. - reasons
- Whether/how to handle more esoteric cases when determining differences.
- reasons.constructor
false|true - Whether to consider constructor. This affects, amongst others, Box Primitives (
new Boolean(true)vstrue) and TypedArrays (new Int8Array([1,2])vsnew Uint8Array([1,2])) where differenes are pedantic. - reasons.descriptors
false|true - Whether to consider property non-enumerability descriptors (configurable, getter vs value, writeable).
- reasons.promise
'ref'|'value' - How to determine equality of promises.
- reasons.prototype
false|true - Whether to consider prototype.
- reasons.weak
'ref'|'value' - How to determine equality of Weak objects (
WeakMap,WeakRef,WeakSet).
Deviations
type Deviations = Iterator<
(string | Symbol)[], // ['foo', '1', Symbol('zed')]
{
actual:
| bigint
| boolean
| null
| number
| string
| symbol
| undefined
,
expected:
| bigint
| boolean
| null
| number
| string
| symbol
| undefined
,
reason: {
constructor?: boolean,
descriptor?: boolean,
enumerability: boolean,
equality: boolean,
missing: boolean,
prototype?: boolean,
reference: boolean,
type: boolean,
},
},
>;
- path
- An array containing an ordered list (where the first is the outmost and the last is the current) of keys representing the path to the deviation (
['foo', 1, Symbol('zed')]). When comparing non-objects, (eg strings), path is an empty array ([]). - actual
- The leaf value from the second argument.
- expected
- The leaf value from the first argument.
- reason
-
The reason(s) comparison failed to match.
{ expected: undefined, actual: undefined, reason: { missing: true, … }, }Reason(s) are general to specific, outter-most to inner-most:
compare(true, new Date())→ "type" is the reason for the deviation. Specific order is engine-defined.
Equality
To avoid suppressing potentially relevant differences, primitive values are compared with SameValue (which does not differentiate any NaN but does differentiate -0 from 0/+0). This might become configurable via CompareOptions.
- TypedArrays containing the same values in the same sequence are equal, except when
CompareOptions.reasons.constructoris enabled. - A box primitive (eg
new Boolean(true)) equals its primitive (egtrue), except whenCompareOptions.reasons.constructoris enabled.
Custom types are handled by HostTypes (to avoid custom comparison).
Examples
Fast mode: equal
compare('a', 'a');
undefined
Fast mode: unequal
compare('a', 'b');
true
Full mode: unequal
compare('a', 'b', { mode: 'full' });
Iterator => Iterable(1) {
[] => {
expected: 'a',
actual: 'b',
reason: { equality: true, … },
},
}
Fast mode: object descriptor vs literal
compare(
Object.create({}, { foo: { enumerable: true, value: 'a' } }),
{ foo: 'a' },
);
undefined
compare(
Object.create({}, { foo: { enumerable: true, value: 'a' } }),
{ foo: 'a' },
{ reasons: { descriptor: true } },
);
true
compare(
Object.create({}, { foo: { enumerable: true, get: () => 'a' } }),
{ foo: 'a' },
);
undefined
compare(
Object.create({}, { foo: { enumerable: true, get: () => 'a' } }),
{ foo: 'a' },
{ reasons: { descriptor: true } },
);
true
Full mode: type unequal
compare('1', 1, { mode: 'full' });
Iterator => Iterable(1) {
[] => {
expected: '1',
actual: 1,
reason: { type: true, … },
},
}
Full mode: non-enumerable value
compare(
Object.create({}, { foo: { enumerable: false, value: 'a' } }),
{ foo: 'a' },
{ mode: 'full' },
);
Iterator => Iterable(1) {
['foo'] => {
expected: undefined,
actual: 'a',
reason: { enumerability: true, … },
},
}
Full mode: non-enumerable getter
compare(
Object.create({}, { foo: { get: () => 'a' } }),
{ foo: 'a' },
{
mode: 'first',
},
);
Iterator => Iterable(1) {
['foo'] => {
expected: undefined,
actual: 'a',
reason: { enumerability: true, … },
},
}
Full mode: multiple leafs unequal, plus red-herring from type-mismatch
compare(
{ foo: 'a', bar: 'c' },
{ foo: 'b', bar: 2 },
{
mode: 'full',
},
);
Iterator => Iterable(2) {
['foo'] => {
expected: 'a',
actual: 'c',
reason: { equality: true, … },
},
['bar'] => {
expected: 'c',
actual: 2,
reason: { equality: true, … },
},
}
Full mode: multiple leafs unequal and missing
compare(
{ foo: { bar: 'a' } },
{ foo: { bar: 'b', qux: 'c' } },
{ mode: 'full' },
);
Iterator => Iterable(2) {
['foo', 'bar'] => {
expected: 'a',
actual: 'b',
reason: { equality: true, … },
},
['foo', 'bar', 'qux'] => {
expected: undefined,
actual: 'c',
reason: { missing: true, … },
},
}
Full mode: multiple leafs unequal and prototype
compare(
{ foo: 'a', __proto__: null },
{ foo: 'b' },
{
mode: 'full',
reasons: { prototype: true },
},
);
Iterator => Iterable(2) {
'[[Prototype]]' => {
expected: null,
actual: Object,
reason: { prototype: true, … },
},
['foo'] => {
expected: 'a',
actual: 'b',
reason: { equality: true, … },
},
}
Full mode: multiple array items unequal and missing
compare(
['a', 'b', 'c' ],
['a', 'b', 'd', 'e'],
{
mode: 'full',
},
);
Iterator => Iterable(1) {
[2] => {
expected: 'c',
actual: 'd',
reason: { equality: true, … },
},
[3] => {
expected: undefined,
actual: 'e',
reason: { missing: true, … },
},
}
Sibling proposals
The current proposal is useful on its own and sets a foundation for the following to be addressed subsequently.
The current proposal does not include features likely to attract customisation, so punting these delays the need to determine how customisation will be facilitated.
Other related proposals
Prior art
Assertions and expectations
The vast majority of ECMAScript engineers use one of 2 forms: assert and expect. These come from one of ~4 libraries: chai (20M weekly), jasmine (1.4M weekly), jest (29M weekly), node:assert (indeterminable). These are direct competitors, so we can assume there is no overlap and the numbers are summable: at least ~51M weekly (probably significantly higher when node:assert numbers are added).
Assert
node:assertandchai's TDD set have large overlap.
Expect
jasmineandjestare (nearly?) identical with dedicated methods:expect(a).toEqual(b)chai's BDD set is a chain-style that builds upon itself:expect(a).to.equal(b)
Neighbours
Many major languages natively include a form of assertion. To name a relevant few: