For AI agents: the complete documentation index is available at /tc39-atlas/en/llms.txt, the full documentation bundle is available at /tc39-atlas/en/llms-full.txt, and this page is available as Markdown at /tc39-atlas/en/proposals/stage/1/proposal-comparisons.md.
  • English
  • Comparisons S1

    Proposal details
    Proposal overview

    This proposal introduces a standardized deep comparison API to address the lack of built-in support for determining equality and differences between values, especially objects. It provides a compare function that can return either a boolean (fast mode) or an iterator of deviations (full mode), with configurable options for handling edge cases like constructors, prototypes, and descriptors. The proposal is foundational and intended to support future sibling proposals like Inspector and Modes.

    Note

    The README below comes from the upstream repository and may contain outdated stage or status metadata. Use the proposal details above as the current source of truth.

    Proposal: Comparisons

    Champions:

    Authors:

    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 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 | 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.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.

    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