typescript/object/deep-equal@1

deep-equal

Compare two values by the data they carry rather than by reference, walking into arrays, objects, Map, Set, typed arrays, Date, RegExp and Error, terminating on graphs that return to themselves, and never writing to either argument.

Install

npx toopo add object/deep-equal

Signature

type DeepEqual = (left: unknown, right: unknown) => boolean

13 327 bytes, one file

0 imports

49 settled cases

What it does

Answers whether two JavaScript values carry the same data. === compares references, and the spelling a caller reaches for instead - comparing two JSON.stringify results - is wrong on seven of nine ordinary pairs: it answers false for {a:1,b:2} against {b:2,a:1}, true for a populated Map against an empty one, true for a Date against its own ISO string, true for -0 against 0, and it throws on a cyclic value and on a BigInt. The cause is one line of the language: Object.keys of a Set is the empty array, so a collection carries its contents where no property walk reaches. Measured on fast-deep-equal 3.1.3, deep-equal 2.2.3, dequal 2.0.3, lodash 4.18.1 and the runtime's own util.isDeepStrictEqual, eleven of twenty-eight ordinary pairs are answered differently by at least two of them - and two of those five answer true for new Set([1]) against new Set([2]) and for a populated Map against an empty one, because they walk own properties and a Set has none. The entry point their README sends a caller to for collections answers false for a Set of objects against its own structured clone. This contract settles all of it, and settles two things nothing else does: the comparison does not consume the call stack in proportion to the depth of ordinary structure, and a candidate match inside a collection that fails leaves nothing behind it.

ECMAScript compares two values structurally nowhere: Object.is and === answer about references, and JSON.stringify is not a comparison. Read on 2026-08-23, three stage 1 proposals touch this and none would answer it. Composites, last moved 2026-08-18, gives value equality to purpose-built frozen string-keyed structures compared with === after interning - it is shallow, cannot hold a cycle and cannot be asked about two objects that already exist. Array Equality, last moved 2021-04-22, is scoped to arrays. Comparisons, last moved 2026-06-11, is about assertion functions and its open question is whether it should cover rich equality comparisons at all; that is the proposal this contract is re-examined against. The runtime's util.isDeepStrictEqual is a host's assertion helper, absent from the browser, specified nowhere, and the first of six to fall over on depth.

What it is for, and what it is not

The values structuredClone carries: primitives, plain objects, arrays, Map, Set, Date, RegExp, Error, typed arrays, ArrayBuffer, DataView, boxed primitives, and any graph made of them, including one that returns to itself. It is not a schema validator, not a similarity score and not an assertion - it answers and never throws. It does not read into a Promise, a WeakMap, a WeakSet, a WeakRef or a function, because nothing about their contents is observable; two distinct such values are answered false rather than guessed at. It compares prototypes, so an instance of a class and a plain object carrying the same fields are two different things, and a caller who wants them equal wants a different function.

Try it on your own input

This calls deepEqual on whatever you type. What you type into a field is the value, character for character, and the form opens on not-a-number-equals-itself so there is a call that works to edit. left and right are written as a literal instead, exactly the way the cases above are written, because what it takes is any value at all, which is what this contract compares. What comes back is what the function answered, under the call it was made from — invisible characters are named there, so two inputs that look alike on screen do not print alike. The settled answer is on the case's own line below, and is deliberately not repeated here.

The JavaScript this runs is object/deep-equal's own reference.ts with its types stripped. That is neither the file the registry serves nor the file its digest covers: both are TypeScript, and no browser runs TypeScript. It is also the only part of this page that needs JavaScript at all.

Reference

Everything above answers whether this function does what you need. Everything below is what it is bound to do, in full. It is long on purpose, and none of it is folded away: a case a reader cannot find with their browser's own search is a case this catalogue did not really publish.

Signature

You get deepEqual.

type DeepEqual = (left: unknown, right: unknown) => boolean

49 settled cases

Every one of them is named, frozen with the major version, and linkable. This is what the contract decides, one input at a time.

the pairs this contract settles, each answered by the walk and by the transposition of itself, because an order-insensitive matching can answer differently by side.

Identity of a primitive

Where the walk stops before it starts. Every one of these is decided by Object.is, which is the language's own rule and the reason NaN equals itself here while -0 does not equal 0.

deepEqual(NaN, NaN) → true

#not-a-number-equals-itself

NaN === NaN is false and Object.is(NaN, NaN) is true. A comparison about data takes the second: a caller holding two records that both failed to parse a number is holding the same record twice, not two different ones.

deepEqual(-0, 0) → false

#negative-zero-is-not-zero

The other half of Object.is, and the half the ecosystem splits on: lodash and fast-deep-equal answer true, util.isDeepStrictEqual answers false. A refund that rounded away to nothing keeps the mark saying which way the money went - the same argument number/round@1 freezes one contract over.

deepEqual({ z: -0 }, { z: 0 }) → false

#negative-zero-nested-is-still-not-zero

The same rule one level down, because a rule that held only at the root would be a property of the entry point rather than of the walk.

deepEqual({ v: 1n }, { v: 1 }) → false

#a-bigint-is-not-its-number

1n == 1 is true and Object.is(1n, 1) is false. Two values a caller cannot add together are not the same datum, and the loose comparison is the one nothing here uses.

deepEqual(new String('a'), 'a') → false

#a-boxed-primitive-is-not-its-primitive

One is an object with a prototype and own indexed properties, the other is a primitive. typeof separates them and so does anything a caller does with them afterwards.

The shape of a plain object

deepEqual({ a: 1, b: 2 }, { b: 2, a: 1 }) → true

#the-order-keys-were-written-in-is-not-read

The first thing the spelling a caller reaches for gets wrong: comparing two JSON.stringify results answers false here, because a serialisation carries an order the data does not have.

deepEqual({ a: undefined }, {}) → false

#a-key-holding-undefined-is-not-a-missing-key

The two are different to Object.keys, to in, and to a caller writing if ("a" in row). The JSON.stringify spelling answers true, because undefined is dropped on the way out.

deepEqual({ [#1 = Symbol('object/deep-equal@1 case')]: 1 }, { [#1]: 2 }) → false

#data-under-a-symbol-key-is-data

Measured, four of six shipped implementations answer true here: they walk Object.keys, which does not report a symbol. Two objects whose data differs compare equal, and nothing about the call says so.

deepEqual(<an instance of a class>, { x: 1 }) → false

#a-class-instance-is-not-its-fields

The prototype is part of what the value is: methods, a constructor and whatever else the class carries are all reachable from one and not from the other. Every implementation measured agrees, and inputDomain says in as many words that a caller who wants them equal wants a different function.

deepEqual(Object.assign(Object.create(null), { a: 1 }), { a: 1 }) → false

#a-null-prototype-object-is-not-a-plain-one

The same rule at the other end of the prototype chain, and the place the ecosystem parts again: lodash answers true, util.isDeepStrictEqual answers false. An object built to have no inherited toString is not the object that has one.

deepEqual({ a: 1 }, { a: 1 }) → true

#a-getter-is-read-as-the-value-it-returns

A comparison reads values, and reading a property is what a caller does too. The alternative - comparing descriptors - would answer about how an object was written rather than about what it carries, and would make a memoised object differ from its own plain copy.

Arrays and their holes

An array is compared by its own keys, so a hole and a stored undefined are different things - which is what Object.keys says about them and what nothing that walks values alone can see.

deepEqual([, 1], [undefined, 1]) → false

#a-hole-is-not-an-undefined

Object.keys([,1]) is ["1"] and Object.keys([undefined,1]) is ["0","1"], so the two differ in what they hold and not only in what they read as. map and forEach skip the hole and visit the undefined, which is where a caller meets the difference.

deepEqual([1, 2], [1, 2, ,]) → false

#a-trailing-hole-changes-the-length

Own keys are identical here and the lengths are 2 and 3, so this is the row that says the walk compares an array's length as well as its keys. Without it a table built only from key sets would pass an implementation that dropped the length check.

Collections carry what no key walk sees

The reason this contract exists. Object.keys(new Set([1])) is the empty array, so an implementation that walks own properties sees two empty objects and says they match - measured, two of five shipped implementations answer true for new Set([1]) against new Set([2]).

deepEqual(new Set([1]), new Set([2])) → false

#two-sets-of-different-members-are-different

The sharpest instance in the catalogue of a silent wrong answer: measured, [email protected] and dequal/[email protected] both answer true, because a Set has no own enumerable property and a walk over keys sees two empty objects.

deepEqual(new Set([1, 2]), new Set([1])) → false

#two-sets-of-different-size-are-different

The same two implementations answer true here too, which is what separates this from the row above: it is not that they compare members badly, it is that they do not look at the collection at all.

deepEqual(new Map([['a', 1]]), new Map([])) → false

#a-populated-map-is-not-an-empty-one

The JSON.stringify spelling also answers true here, and for the same reason as the two above rather than a different one - a Map serialises as {}. Three ways of asking, one blindness.

deepEqual(new Map([['a', 1]]), new Map([['a', 2]])) → false

#a-map-value-is-compared

A Map is compared by its entries and not only by its keys, which is the half an implementation comparing sizes and key sets would pass without.

deepEqual(new Set([1, 2]), new Set([2, 1])) → true

#the-order-members-were-added-in-is-not-read

Insertion order is observable on a Set - it decides iteration - and it is not part of what the Set holds. A caller who built the same set of ids from two different queries has one set of ids.

deepEqual(new Set([{ id: 1 }]), new Set([{ id: 1 }])) → true

#a-set-member-is-compared-by-its-data

The other direction of the same blindness, and the one the ecosystem's remedy gets wrong: fast-deep-equal/es6 - the entry point its README sends a caller to for collections - answers false, because it tests membership with has, which is reference identity.

deepEqual(new Map([[{ k: 1 }, 'v']]), new Map([[{ k: 1 }, 'v']])) → true

#a-map-key-is-compared-by-its-data

The same for a key rather than a member, which is the harder half: matching entries without an order means a key of one has to be found among the unclaimed keys of the other, and that is the only quadratic path in the contract.

A value in an internal slot

The same blindness one floor down: a Date, a boxed number and a typed array carry their value where no own property is, and a walk that enumerates keys sees nothing to compare.

deepEqual(new Date(0), new Date(0)) → true

#two-instants-are-one-date

A Date carries a number of milliseconds in a slot and has no own property at all. Two dates naming one instant are one datum.

deepEqual(new Date(NaN), new Date(NaN)) → true

#an-invalid-date-equals-an-invalid-date

The slot holds NaN, and the rule is the one the first group settles: Object.is and not ===. Measured, three of five shipped implementations answer false here, which is NaN's own trap arriving inside a built-in.

deepEqual(new Date(0), new Date(NaN)) → false

#an-invalid-date-is-not-an-instant

The neighbour of the row above, and the reason that row is about Object.is rather than about treating every invalid date as equal to everything.

deepEqual(/ab+c/gi, /ab+c/gi) → true

#a-pattern-is-its-source-and-its-flags

The two things a pattern is. Everything else about a RegExp object is derived from them or is the state of a scan, which the last row of this group settles.

deepEqual(/a/g, /b/g) → false

#a-pattern-source-is-part-of-it

The half of the row above that says source is read.

deepEqual(/a/g, /a/i) → false

#a-pattern-flag-is-part-of-it

The other half, and not the same claim: an implementation comparing only source passes the row above and fails here.

deepEqual(/a/g, /a/g) → true

#where-a-pattern-stopped-is-not-part-of-it

util.isDeepStrictEqual reads lastIndex and answers false; lodash, dequal and fast-deep-equal ignore it. This contract excludes it and the argument is not a show of hands. Measured: structuredClone(/a/g) with lastIndex at 3 returns a pattern whose lastIndex is 0, with source and flags identical, and the same for a sticky pattern. The declared domain is the values structuredClone carries and the admission oracle is that a value equals its own clone, so reading lastIndex would make this contract fail the oracle it was admitted on. util.isDeepStrictEqual is bound by no such domain; this contract is.

deepEqual(new Number(7), new Number(8)) → false

#a-boxed-number-carries-a-number

A row the author of this contract got wrong. A boxed number has no own property, so a walk that enumerates keys sees two empty objects and answers true - which is what this contract's own walk did, silently, until the dispatch was measured. lodash and util.isDeepStrictEqual were right about it all along.

deepEqual(new Boolean(true), new Boolean(false)) → false

#a-boxed-boolean-carries-a-boolean

The same slot blindness on a second kind, kept as its own row because a dispatch repaired for Number alone passes the row above and fails here - which is how the repair was checked.

deepEqual(Object(1n), Object(2n)) → false

#a-boxed-bigint-carries-a-bigint

The third kind, and the one that is easiest to leave out because Object(1n) is not a spelling anybody writes on purpose - which is exactly why a caller meets it through a library rather than through their own code.

deepEqual(new Number(7), Object.assign(new Number(7), { tag: 1 })) → false

#a-slot-does-not-hide-an-own-property

The slots agree and the own keys do not. Measured, lodash answers true here - it reads the slot and stops - so this row is one of the two places this contract is stricter than the most-used implementation rather than looser.

deepEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2])) → true

#two-typed-arrays-of-one-kind-are-their-elements

A typed array has indexed own properties, so a key walk finds something - which is why this row passes almost everywhere and the next two do not.

deepEqual(new Uint8Array([1, 0]), new Uint16Array([1])) → false

#a-typed-array-kind-is-part-of-it

Same bytes, different kinds, different lengths, and a caller cannot use one where the other is expected. The prototypes differ, which is what the walk reads.

deepEqual(new Float64Array([NaN]), new Float64Array([NaN])) → true

#not-a-number-inside-a-float-array-equals-itself

The row that caught three of the five shipped implementations against the oracle: they compare elements with ===, so a float array holding NaN is not equal to its own structured clone. Object.is per element is the rule, and it is the first group's rule again rather than a new one.

deepEqual(new Float64Array([-0]), new Float64Array([0])) → false

#negative-zero-inside-a-float-array-is-not-zero

The other half of Object.is inside a typed array, and the half an implementation comparing bytes would get right by accident - which is a different implementation from one that got the row above right.

An error is data

deepEqual(new Error('x'), new Error('x')) → true

#two-errors-of-one-message-are-one-error

An error caught and stored is a value like any other. stack is not read, because it names where the error was constructed rather than what it says, and two errors built at two call sites for one reason are one reason.

deepEqual(new Error('a'), new Error('b')) → false

#a-message-is-part-of-an-error

Measured, dequal answers true here - it walks own enumerable properties and message is not one of them. The same blindness as the collections, on the type a caller is most likely to be comparing when something has gone wrong.

deepEqual(new Error('a', { cause: 1 }), new Error('a', { cause: 2 })) → false

#a-cause-is-part-of-an-error

cause is where the original failure is kept, so an implementation reading name and message alone answers true for two errors that happened for different reasons. It is compared as data rather than by identity, so a cause that is itself an object is walked.

deepEqual(new TypeError('x'), new Error('x')) → false

#an-error-kind-is-part-of-it

The prototype rule of the second group, arriving where a caller most often branches on it: code that asks err instanceof TypeError is asking about exactly this difference.

A graph that returns to itself

Terminating is half of it. The other half is that a comparison which remembers what it has already assumed must forget an assumption it made speculatively, and the last two rows are the ones the author got wrong.

deepEqual(#1 = { name: 'root', self: #1 }, #2 = { name: 'root', self: #2 }) → true

#a-cycle-terminates

Measured, three of six shipped implementations do not answer this at all - they exhaust the call stack. Answering is the claim; answering true is the second half, and it is the coinductive reading: two graphs that unfold to the same infinite tree are the same data.

deepEqual(#1 = new Set([#1]), #2 = new Set([#2])) → true

#a-set-containing-itself-terminates

The same claim through a collection rather than through a property, and it is not the same test: matching members recurses, so an implementation whose cycle detection lives only in the property walk answers the row above and never returns from this one.

deepEqual(#1 = { name: 'root', self: #1 }, { name: 'root', self: { name: 'root', self: null } }) → false

#a-cycle-is-not-the-same-as-its-unrolling

The row that stops the one above from being satisfied by an implementation that answers true whenever it has seen a pair before. A cycle and a finite unrolling of it part at the first place the unrolling ends.

deepEqual({ s: new Set([#1 = { v: 1 }, { v: 2 }]), also: [#1] }, { s: new Set([{ v: 2 }, { v: 1 }]), also: [{ v: 2 }] }) → false

#a-failed-candidate-leaves-nothing-behind

The row the author of this contract got wrong, in the hour after writing down the danger of cycle detection. The two Sets match either way round; also holds {v:1} against {v:2}. An implementation that memoises the pairs a failed candidate tried answers true, because the failed attempt left ({v:1}, {v:2}) marked as assumed equal. All four shipped implementations answer false, so this row founds no admission - what it founds is the clause, and the evidence for the clause is that the fault was committed here.

Since this was published: The two rows are correct rows - false is the answer, and every shipped implementation measured gives it - and they do not separate the defect their rationale names. The clause the contract states about speculation stands; what does not stand is that these two rows witness it. Nothing in this repository could have said so: a rationale is prose beside a correct answer, and only injecting the defect and watching nothing redden found it.

Measured at 3ec621c by injecting exactly that defect into this contract's own reference - the mutant object-deep-equal · DE-01, which replaces the line taking a pair back off the path with a no-op - and running it against the sound version over the four forms of the witness. The memoising walk answers false on this row and on and-answers-the-same-either-way-round, exactly as the sound one does. It answers true only when the keys are transposed and the right-hand also holds the very Set member the failed candidate tried; the witness these rows are built from holds a fresh object there, and the path is keyed by identity, so the pair the failed candidate left behind is never asked for again. The reading is of this reference and of no other: an implementation that memoises differently may well answer true here.

deepEqual({ also: [#1 = { v: 1 }], s: new Set([#1, { v: 2 }]) }, { also: [{ v: 2 }], s: new Set([{ v: 2 }, { v: 1 }]) }) → false

#and-answers-the-same-either-way-round

The same two values with their two keys declared in the other order, and nothing else changed. The walk that got the row above wrong answered false here, so a table carrying only one of the two would have been green on half the defect - which is what makes this a row rather than a note.

Since this was published: The sentence is true of this row and false of the pair. so a table carrying only one of the two would have been green on half the defect reads as though the two rows split the defect between them; measured, both are green on all of it. What the pair still does is what a transposition is for - it holds the answer steady under a reordering nothing should read - and that is a smaller claim than the one published.

The same reading at 3ec621c, and this row is wrong about its neighbour rather than about itself: the memoising walk really does answer false here, and it also answers false on the row above. Transposing the keys is one of the two things the separating form needs and this row carries that one; what it does not carry is the right-hand also holding the very Set member the failed candidate tried.

What the domain does not carry

Values structuredClone refuses. The contract answers false rather than guessing or throwing, because a silent true on two different functions is the defect this whole catalogue is written against.

deepEqual(<a value whose contents cannot be read>, <a value whose contents cannot be read>) → false

#two-promises-are-not-compared

Nothing about a promise's contents is readable synchronously, so there is nothing to compare. false rather than a throw, because the caller asked a question with an answer: these are not the same value.

deepEqual(<a value whose contents cannot be read>, <a value whose contents cannot be read>) → false

#two-weak-collections-are-not-compared

A WeakMap cannot be enumerated by design. Answering true for two of them - which the author's own walk did, because they have no own property - is the silent true this contract exists to refuse, arriving on a type nobody thinks to test.

deepEqual(<a function, served as a file>, <a function, served as a file>) → false

#two-functions-are-not-compared

Two functions are equal when they are the same reference and not otherwise: source text is not behaviour, and a closure carries what no reading of it shows. The author's walk answered true for these two as well.

What a clone does not preserve

p1-reflexive-under-a-copy is the oracle this contract was admitted on, and it is not unconditional. structuredClone returns a plain object for a class instance and for a null-prototype object, so a value of either shape is not equal to its own clone - measured, and util.isDeepStrictEqual answers the same. The property's alphabet excludes both, and these rows are where they are settled instead.

deepEqual(<an instance of a class>, { x: 2 }) → false

#a-class-instance-is-not-its-own-clone

structuredClone returns a plain object for a class instance - measured, the prototype goes from the class to Object - so the clone is not the same kind of thing. This row is what p1-reflexive-under-a-copy excludes rather than what it asserts, and it was found by fast-check on the first hundred draws while the property was being calibrated.

deepEqual(Object.assign(Object.create(null), { b: 2 }), { b: 2 }) → false

#a-null-prototype-object-is-not-its-own-clone

The same flattening at the other end: a null-prototype object clones to a plain one. util.isDeepStrictEqual answers false here too and lodash answers true, so the ecosystem is split on the row that decides how the oracle has to be stated.

Properties

3 of these 4 are checked on 1 000 generated cases per run, re-seeded each time. The other one is not applicable to this contract, and says why below.

Benchmark profiles

The shapes of input an implementation is timed on. No figures yet: there is no reference machine, and a number produced on a developer laptop would be a number with nothing behind it.

How this contract measured

2 batteries break this implementation on purpose, 26 times, and require the contract's own suite to notice each time. 24 of those defects were caught.

2 cells survived, and what did is here rather than on a page of its own, because what a suite did not catch is a fact about this function. Each carries the battery's own account of why, in the instrument's words and not this page's.

2 — its witness is frozen out

the edit is a real defect and an input tells it from the correct version - measured, not assumed. The row that would carry that input cannot be added: the contract owning it is published, and its cases are inside the digest a lockfile holds. It is the one kind here that nothing closes short of a second major.

object-deep-equal-spec · DS-05 on S/as-committed

adds a fourth class to the profile vocabulary that no profile carries, so the registry serves a word a reader is handed and nothing measures. It survives, and the guard written for it is why: every-class-the-vocabulary-declares-is-sampled re-declares the union by hand - three strings in an array beside the type - so a fourth member of the type leaves its list at three and it passes. Its own comment claims *both directions*, and the second one is dead: a profile declaring a class outside the union does not compile, and a class inside it that no profile carries reddens nothing. Totality would need the union derived from a runtime list rather than transcribed from the type, and profiles.test.ts is one of the seven files inside this contract digest. ADR-0161

object-deep-equal · DE-01 on E/as-committed

memoises the pairs it has compared instead of keeping them on a path, so a candidate that failed inside a Set leaves the pair it tried marked as equal. This is the fault this file was written with, and the answer it produces depends on the order the keys of an object were declared in. The two rows written to witness it do not separate it, and only the replay said so: measured over the four forms, false against false as the witness is published in either key order, and false against true only when the keys are transposed *and* the right-hand also holds the very Set member the failed candidate tried. The published witness holds a fresh object there, and the path is keyed by identity - so nothing is ever found again. The row that would witness it is a row of caseTables, which is inside the digest a lockfile holds, so it cannot be added

What you can check yourself

This definition is frozen. Its canonical text hashes to cf0ee281d97aa77d85623e649232e6d7a04d72d3bc607fa27172636a2ba53843, and the 7 files of its test harness are listed inside it with their own hashes — so a copy of the harness can be checked against this definition before it is trusted, then run against any implementation, without taking our word for any of it.

Written for node, browser, bun.