Champions:
- Jacob Smith (@JakobJingleheimer)
- Richard Gibson (@gibson042)
Authors:
- Jacob Smith (@JakobJingleheimer)
- Ruben Bridgewater (@BridgeAR)
Current: 1
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.
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.
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 === falseBut 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 BBetter
if (A !== B) throw new Error(`${A} does not equal ${B}`);
// Error: 1 does not equal 2But brittle
if (A !== B) throw new Error(`${A} does not equal ${B}`);
// Error: [object Object] does not equal [object Object]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',
});
}log('bad data', compare(initiallyGood, nowBad));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).
Input from an uncontrolled origin:
try {
assert.is(
total += value,
NaN,
);
} catch (err) {
toast(…);
}{items.map(({ id, label }) => (
<button onClick={() => remove(id)}>
{label}
</button>
))}assert.equal(
{ foo: 1 },
{ foo: 1, bar: 2 },
);- This is not a test runner (
describe,it, etc). - This is not a test utility suite (
mock,stub, etc).
A function to deeply compare values.
function compare(
expected: any,
actual: any,
options: CompareOptions,
): (true | Iterator<Deviation>) | undefined;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).
type Deviations = Iterator<
string, // "foo['bar-qux']['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,
},
},
>;- key
- A bracket-notation path like
"foo['bar-qux']['zed']". When comparing non-objects, (eg strings), the path is an empty string"". - 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.
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).
compare('a', 'a');
undefinedcompare('a', 'b');
truecompare('a', 'b', { mode: 'full' });
Iterator => Iterable(1) {
"" => {
expected: 'a',
actual: 'b',
reason: { equality: true, … },
},
}compare(
Object.create({}, { foo: { enumerable: true, value: 'a' } }),
{ foo: 'a' },
);
undefinedcompare(
Object.create({}, { foo: { enumerable: true, value: 'a' } }),
{ foo: 'a' },
{ reasons: { descriptor: true } },
);
truecompare(
Object.create({}, { foo: { enumerable: true, get: () => 'a' } }),
{ foo: 'a' },
);
undefinedcompare(
Object.create({}, { foo: { enumerable: true, get: () => 'a' } }),
{ foo: 'a' },
{ reasons: { descriptor: true } },
);
truecompare('1', 1, { mode: 'full' });
Iterator => Iterable(1) {
"" => {
expected: '1',
actual: 1,
reason: { type: true, … },
},
}compare(
Object.create({}, { foo: { enumerable: false, value: 'a' } }),
{ foo: 'a' },
{ mode: 'full' },
);
Iterator => Iterable(1) {
"foo" => {
expected: undefined,
actual: 'a',
reason: { enumerability: true, … },
},
}compare(
Object.create({}, { foo: { get: () => 'a' } }),
{ foo: 'a' },
{
mode: 'first',
},
);
Iterator => Iterable(1) {
"foo" => {
expected: undefined,
actual: 'a',
reason: { enumerability: true, … },
},
}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, … },
},
}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, … },
},
}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, … },
},
}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, … },
},
}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.
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).
node:assertandchai's TDD set have large overlap.
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)
Many major languages natively include a form of assertion. To name a relevant few: