Skip to content

tc39/proposal-comparisons

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

12 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Proposal: Comparisons

Champions:

Authors:

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 true when deviation(s) exist or undefined when no deviation exist.
mode full
Return an Iterator of Deviations with all deviations, or undefined when 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) vs true) and TypedArrays (new Int8Array([1,2]) vs new 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, // "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.

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.constructor is enabled.
  • A box primitive (eg new Boolean(true)) equals its primitive (eg true), except when CompareOptions.reasons.constructor is 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:assert and chai's TDD set have large overlap.

Expect

  • jasmine and jest are (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:

Proposals

About

ECMAScript Proposal, specs, and reference implementation for comparisons

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Contributors