Proposal: Object.isEqual() - Deep Structural Equality for ECMAScript

Proposal: GitHub - dwight-trujillo/object-isEqual: Deep structural equality polyfill for ECMAScript - TC39 Stage 0 Proposal. Compares any JavaScript values by structure: objects, arrays, Map, Set, Date, RegExp, TypedArrays, and more. Zero dependencies. Security hardened. ยท GitHub

Title: Object.isEqual() - Deep Structural Equality

Author: Dwight Trujillo

Stage: 0 (Strawman)

Abstract:
Object.isEqual(value1, value2[, options]) provides deep structural equality comparison for ECMAScript. Currently JavaScript has no built-in way to compare objects by structure - developers rely on Lodash (1B+ weekly downloads) or fragile JSON.stringify workarounds. This proposal fills that gap with a native, secure, and configurable solution.

Motivation:

  • No native deep equality in JavaScript
  • Lodash _.isEqual: 1B+ weekly npm downloads
  • 43% of developers cite lack of standard library as major pain point
  • JSON.stringify comparison fails with Date, RegExp, Map, Set, NaN, circular references

Basic API:

Object.isEqual({a:1, b:{c:2}}, {a:1, b:{c:2}}); // true
Object.isEqual(new Date('2025-01-01'), new Date('2025-01-01')); // true
Object.isEqual(0, -0, { strict: true }); // false
Object.isEqual(obj1, obj2, { customComparators: { ts: () => true } });

Key Features:

  • SameValueZero by default (NaN = NaN, 0 = -0)

  • Strict mode via { strict: true }

  • Custom per-key or global comparators

  • Circular reference detection

  • All native types supported (Map, Set, Date, RegExp, TypedArrays, etc.)

  • Security hardened (anti-spoofing, cross-realm safe, DoS limits)

  • Zero dependencies

  • Production-ready polyfill available: npm install object-is-equal

Repository: https://github.com/dwight-trujillo/object-isEqual
Full Proposal: https://github.com/dwight-trujillo/object-isEqual/blob/90834afa31d7e4e214155a71be9cd746799b7ce5/proposal.md
Live Docs: https://dwight-trujillo.github.io/object-isEqual/
Executive Report: https://dwight-trujillo.github.io/object-isEqual/docs/executive-report.html

Prior Art:

  • Lodash _.isEqual

  • Node.js assert.deepStrictEqual

  • Python == (built-in value comparison)

  • Java Objects.deepEquals()

Does this overlap with existing proposals?
No. Records & Tuples is about immutable value types. Pattern Matching is about structural matching. This proposal is specifically about deep equality comparison.

Seeking:
Stage 0 consideration and a TC39 champion to help advance this proposal.

The roughest part of this is that by the time you get the result, it is entirely within the realm of possibility that it will not be correct anymore.

That's because isEqual could be triggering getters (or proxies for that matter) and there's no guarantee that those getters or proxies might not call in and change the mutable data you're trying to compare as you're trying to compare it!

E.g.

let left = { a: 'a', b: 'b' };
let right = { a: 'a', get b() { this.a = 'c'; return 'b'; } };

Object.isEqual(left, right); // true
left.b === right.b; // false

Great point, and thank you for raising this. This is a real concern with any deep comparison utility, including Lodash's _.isEqual.

How Object.isEqual Addresses This

  1. Safe Mode (default: true)

When safe: true (the default), any exception thrown by a getter or proxy trap is captured, and the comparison returns false immediately. This prevents the comparison from continuing with potentially corrupted state:

text
const obj = {
get x() {
this.y = Math.random(); // Mutates during read!
return 1;
},
y: 0
};

Object.isEqual(obj, obj); // false (getter threw or safe mode aborted)
Object.isEqual(obj, obj, { safe: false }); // throws error

  1. Own Property Descriptor Reading

For sensitive types like Error, the polyfill reads name and message via Object.getOwnPropertyDescriptor to avoid triggering getters entirely. This could be extended as an option ({ raw: true }) to read ALL properties via descriptors, completely bypassing getters/proxies.

  1. Proposed Addition: snapshot option

This discussion has highlighted the need for a snapshot option. I'd like to propose adding it to the spec:

text
Object.isEqual(obj1, obj2, { snapshot: true });

When snapshot: true, the algorithm would:

  • Read all own enumerable properties via Object.getOwnPropertyDescriptor
  • Create a shallow copy of each value before comparison
  • Compare the snapshots instead of the live objects

This guarantees atomic reads and prevents mutation during comparison.

  1. Documented Limitation

The polyfill already documents that Proxy objects are not reliably comparable:

"Proxy objects can intercept all read operations and are not compared reliably. Passing proxies may yield unpredictable results."

Prior Art

Even JSON.stringify and structuredClone face the same issue. The snapshot approach is similar to how React's useState works โ€” you read the state once, then compare the snapshot.

Open Question for the Committee

Should the spec:

(A) Default to safe mode with getter protection?
(B) Include a snapshot option for guaranteed atomic comparison?
(C) Both?

I lean toward (C) โ€” safe: true by default, with an optional snapshot: true for guaranteed consistency.

Thanks again for the thoughtful feedback. This is exactly the kind of discussion that makes the TC39 process so valuable.

Thanks again for the feedback โ€” it directly led to a concrete improvement.

I've released v5.2.0 of the polyfill with a new snapshot option:

text
Object.isEqual(obj1, obj2, { snapshot: true });

When enabled, it uses Object.getOwnPropertyDescriptor to atomically read all properties before comparison, creating shallow snapshots. This prevents exactly the race condition you described โ€” getters or proxies cannot mutate data mid-comparison because we compare the frozen-in-time copies.

1 Like

There was already a proposal for shallow equality, and it couldn't even get consensus.

Separately, deep equality imo is a code smell, despite the fact that many libraries and people do it. Additionally, there is no single coherent definition for "equality". How do you equate two functions other than by identity? What if two objects have an identical set of properties but a different [[Prototype]]? etc.

Note as well that lodash v5 will almost certainly not include isEqual, for the reasons indicated and more.

Additionally, I maintain lodash, https://www.npmjs.com/package/extend, https://www.npmjs.com/package/node.extend, https://www.npmjs.com/package/is-equal, and and would still object to any of those archaic modules being used as a justification for a "deep equality" mechanism due to the reasons indicated.

Thanks for the detailed feedback, @ljharb. I appreciate your perspective, especially given your experience maintaining Lodash.

On the shallow equality proposal: I was not aware of it. Could you point me to it? I would like to understand what objections it faced and ensure this proposal does not repeat those mistakes.

On deep equality being a code smell: I agree that unbounded deep equality is problematic โ€” comparing functions, prototypes, or arbitrary objects with unknown shapes has no single correct answer. However, I think there is a narrower, well-defined subset that covers the vast majority of real-world use cases:

  1. Data comparison, not object comparison

The primary use case is comparing data โ€” plain objects, arrays, and primitives that represent state, config, or API responses. These do not have custom prototypes, getters, or functions. In React alone, Object.is is used extensively for state comparison, but it fails for nested objects.

  1. The snapshot option addresses the getter/proxy concern

Version 5.2.0 already includes { snapshot: true } which reads properties atomically via Object.getOwnPropertyDescriptor, bypassing getters entirely.

  1. What this proposal does NOT attempt
  • Does NOT compare functions (always skipped unless a custom comparator is provided)
  • Does NOT traverse prototypes
  • Does NOT handle Proxy objects reliably (documented limitation)
  1. Narrowing the scope further

Based on your feedback, I would be open to:

  • Renaming to Object.dataEqual to emphasize it compares data, not arbitrary objects
  • Making snapshot: true the default
  • Explicitly excluding functions, prototypes, and proxies from the spec
    On Lodash v5 removing isEqual: I was not aware of this. Could you share the reasoning?

Question for you: If the scope were narrowed to data comparison only โ€” plain objects, arrays, primitives, Date, RegExp, Map, Set, TypedArrays โ€” with no prototype traversal, no function comparison, and atomic reads by default โ€” would that address your concerns?

I am not trying to standardize _.isEqual. I am trying to solve the specific problem of comparing structured data, which every major framework and library has to reinvent today.

Additionally, there is no single coherent definition for "equality".

That's why I made the Records (v2) proposal. Though it doesn't offer a specific recordsDeepEqual(a, b) method, that method can be defined without any kind of confusion for what counts as equal, because for it to consider two objects equal both objects must have no prototype. All that's left is to compare keys and values.

@conartista6 Thanks for bringing up Records (v2). That is exactly the direction I am considering โ€” narrowing the definition of equality to data only, where both objects lack a custom prototype.

The idea of Object.dataEqual (renamed from isEqual) would:

  • Only compare own enumerable properties
  • Skip functions entirely
  • Ignore prototypes
  • Use snapshot: true by default for atomic reads
    This aligns with the Records philosophy: if you care about deep equality, you should be comparing data, not arbitrary objects with behavior attached.

Would love to hear if you think Object.dataEqual would complement Records and Tuples, rather than overlap with them.

1 Like

https://github.com/sebmarkbage/ecmascript-shallow-equal

The important thing is to define the problem being solved. As with every proposal, trying to sketch out semantics or an API before the problem statement is clear is rarely worth spending the time.

Also my records proposal as it stands does not give any method for comparing equality.

So yeah, I think the proposals would be complementary.

I'd be fine with a dataEqual method that requires either plain-proto or no-proto data. Would it let plain-proto data be comparable to no-proto data?

Just based on the name, I think I would expect that it would allow that.

Oyyy that's a little spicy coming from you. I've been trying and trying and trying and trying to get you to give feedback on the undertaking to replace Babel, and so far you haven't showed up.

I ask again then: are you willing to look not just at what I'm proposing, but what I'm building? I am making my level best effort to try to work with the people I talk to instead of just pushing back on them.

@ljharb Thanks for the link โ€” I will study the shallow equality proposal to understand what objections it faced.

On defining the problem: agreed. Let me restate it without any API or semantics.

**The Problem**

JavaScript provides === and Object.is for reference equality, but no built-in way to answer the question: *"Do these two values represent the same data?"*

This question arises constantly:

- **State management:** React, Vue, Svelte, and every state library must detect when data has changed to trigger re-renders
- **Configuration:** Is this config object the same as the default?
- **API responses:** Did the server return different data than what we already have?
- **Testing:** Every assertion library reimplements deep equality
- **Caching:** Memoization, useMemo, React.memo all need to know if inputs changed
- **Serialization boundaries:** After JSON.parse, structuredClone, or postMessage, reference equality is lost and you need structural comparison

**Current solutions and their costs:**

> Approach | Problem |
> - | - |
> === / Object.is | Only reference equality โ€” useless for data |
> JSON.stringify(a) === JSON.stringify(b) | Fails on Date, RegExp, Map, Set, NaN, undefined, circular refs, property order |
> Lodash _.isEqual | 1B+ weekly downloads for a single function. Adds dependency. Will be removed in Lodash v5 |
> Custom recursive function | Every team writes their own, often with bugs (cycles, typed arrays, cross-realm) |
> assert.deepStrictEqual | Node.js only |
> structuredClone | Solves cloning, not comparison |

**The core question this proposal asks:**

Should the language provide a standardized answer to "do these two values represent the same data?" โ€” with clear, predictable semantics limited to data (not functions, not prototypes, not proxies)?

If the committee agrees the problem is worth solving, then we can discuss the best API shape. If not, I will withdraw the proposal.

Does that problem statement resonate?
1 Like

What does it mean for an object to "have the same data"? Is a data property and an accessor property that result in "the same" data, considered the same?

Either way, this still isn't explaining why you want it. What specific problem/use case is this solving for you?

@ljharb On accessor vs data property: when snapshot: true is used, accessor properties are read once via Object.getOwnPropertyDescriptor and their value at that moment is compared. Two objects with the same resulting values are considered equal, regardless of whether the property is a data property or an accessor. If the accessor throws, safe: true (default) catches it and returns false.

On the specific use case that motivated this: I was debugging a React application where a component was re-rendering unexpectedly. The state was:

js
const prev = { user: { name: "Alice", age: 30 }, items: [1, 2, 3] };
const next = { user: { name: "Alice", age: 30 }, items: [1, 2, 3] };

Object.is(prev, next) returned false because they were different references โ€” even though the data was identical. JSON.stringify could have worked here, but the real objects contained Date and undefined values. I installed Lodash just for _.isEqual. That added a dependency for a single function call. I thought: this should be built into the language.

Later, I encountered the same need when comparing API responses to avoid unnecessary database writes, and again when writing tests for a state management library. Every time, the solution was either install Lodash, write a custom recursive function, or use a fragile JSON.stringify comparison.

The problem I'm solving is: I have two values and I need to know if they represent the same information, not if they are the same reference. This is not an edge case โ€” it is a daily need in frontend, backend, and testing.

Either way, this still isn't explaining why you want it .

I just think you're wrong to focus so hard on this. A language's designers need to be somewhat proactive, otherwise you end up with a design that feels like a committee made it.

We now have enough stakeholders here who all want this that you're just not really helping anymore by saying "maybe nobody wants this at all". It's time for us to hash out our differences; to figure out if there's anything a diverse group of people can all agree on.

My proposal which I am still missing your feedback on does not have any of the ambiguity you mention. A property is not comparable to an accessor, and an accessor is not comparable as plain data at all. My proposal only compares Records, but if Object.deepFreezeRecord had a companion method Object.deepFreeze, it would make sense for them to share as many semantics as possible.

Therefore: no, I would not consider a getter to be equal to a non-getter. My reasoning is: if you're comparing equality it's because you want to plan for what you're about to do next, usually some kind of caching or no-empty-changesets optimization. If a getter can be compared at all, you simply can't make any meaningful decisions about what optimizations are or aren't possible because you simply don't know whether the getter will return the same result the next time you try to use it

I just realized something tricky. I was wondering to myself why you called your proposed option snapshot: true and indicated that it would make copies of the objects.

I think I now understand. Comparing based on property descriptors gets you most of the way there: getters aren't invoked anymore and there's no confusion about getters VS data properties, which is good.

The fly in the ointment is proxies (again) and specifically the fact that you can trap getOwnPropertyDescriptor.

This problem is so pernicious that I do not believe even the proposed snapshot option would fix it! You're still all the way back here:

let left = { a: 'a', b: 'b' };
let right_ = { a: 'a', b: 'b' };
let right = new Proxy(right_, {
  getOwnPropertyDescriptor(target, prop) {
    let desc = Reflect.getOwnPropertyDescriptor(target, prop);
    if (prop === 'b') {
      right_.b = 'Z';
    }
    return desc;
  },
});

Object.isEqual(left, right, { snapshot: true }); // true
left.b === right.b; // ARRRGH it's false

This is why it's so important to be comparing frozen objects. Yeah behvior can still be triggered by proxies, but with frozen objects the triggered behavior can't change the data you're comparing, and that makes all the difference for being able to have an idempotent definition.

@conartista6 This is extremely helpful, thank you.

On getters vs data properties: I agree completely. A getter is not data โ€” it is behavior that happens to return a value. Comparing getters is meaningless for the use cases we care about (caching, state change detection, deduplication). I will update the polyfill so that when snapshot: true, accessor properties are skipped entirely and only data properties are compared. This removes the ambiguity @ljharb rightfully questioned.

On your proposal: I read through the Records proposal. I see a natural division of labor:

Records and Tuples define what data is โ€” deeply immutable, no prototype, no accessors, comparable by value

Object.dataEqual provides the comparison primitive for plain objects that are not Records but still represent pure data

Both share the same philosophy: compare data, not behavior. I will align this proposal's semantics with Records as closely as possible.

On the proactive design point: "Does this represent the same information?" is a question every developer asks daily. The language should answer it. I am not trying to standardize _.isEqual. I am trying to fill a real gap.

Not just does it represent the same information, but will it. For caching and dedup I think this does matter, though there's some nuance to that discussion as well.

@conartista6 Excellent catch. You are right โ€” getOwnPropertyDescriptor can be trapped by a Proxy and trigger mutations, just like a getter. No amount of defensive reading fixes that.

Your conclusion is the correct one: only frozen objects can be compared safely and idempotently.

This actually simplifies the proposal considerably:

  • Object.dataEqual would throw (or return false) if either argument is not frozen
  • Alternatively, it could implicitly freeze before comparing (but that has side effects โ€” probably better to require explicit freezing)
  • Frozen objects have no getters, no Proxy traps that mutate, no prototype tricks โ€” they are pure data

This aligns perfectly with Records and Tuples, which are deeply frozen by definition.

Updated direction:

js
const a = Object.freeze({ name: "Alice", age: 30 });
const b = Object.freeze({ name: "Alice", age: 30 });
Object.dataEqual(a, b); // true โ€” safe, idempotent, predictable

const c = { name: "Alice" };
Object.dataEqual(c, a); // throws TypeError: both arguments must be frozen
This eliminates the Proxy problem entirely, eliminates the getter problem entirely, and makes the semantics trivial: own enumerable data properties, SameValueZero, recursive.

I will update the polyfill to require frozen objects when snapshot: true. Thank you โ€” this is the kind of feedback that turns a rough idea into a solid proposal.

Cooool! I think we might be on the same page now.

I would freeze objects ahead of comparison though you could also offer something like options.freeze. Since there isn't a trap on setting property descriptors, freezing data should be OK in an idempotent function I think?