Skip to content

toStrictEqual, Bun.deepEquals strict: compare constructors before any per-type comparison - #47837

Draft
robobun wants to merge 3 commits into
mainfrom
robobun/68746faa/strict-equal-builtin-subclass
Draft

robobun wants to merge 3 commits into
mainfrom
robobun/68746faa/strict-equal-builtin-subclass

Conversation

@robobun

@robobun robobun commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Supersedes #37776. Merge after #37764.

Problem

  • expect(new MyMap()).toStrictEqual(new Map()) passes for a subclass MyMap of Map. So does a subclass of Set, Date, Error, RegExp or a typed array, and a Buffer against a Uint8Array. Jest and Vitest reject each pair.
  • Bun__deepEquals (src/jsc/bindings/bindings.cpp:1298) compares class names only after the comparison of those types has answered.

Fix

  • toStrictEqual and Bun.deepEquals(a, b, true) compare classes first. The rule is Jest's: one constructor, two arrays, or one built-in of two realms.
  • Correct because 308 pairs answer as Jest's own equals. 24 named pairs differ.
  • Verified: test/js/bun/test/expect.test.js and test/js/bun/bun-object/deep-equals.test.ts (main fails 38 and 8 tests). Also 307 test files and --aot.
  • Self-reviewed: 46 concerns raised, 44 addressed. Not taken: a census test of byte APIs, and the Cookie#toJSON() producer.

Background

  • Bun__deepEquals is the native walker behind toEqual, toStrictEqual, Bun.deepEquals and node:assert. 2 of its 7 compiled modes change.
  • Considered the name comparison after each per-type comparison. A subclass with no name stays equal to its base.

Downsides

Notes

The rule, in the order the code applies it (src/jsc/bindings/BunStrictClassEquality.h and .cpp)

  1. Only toStrictEqual, strict this.equals and Bun.deepEquals(a, b, true) take the step. toEqual, Bun.deepEquals(a, b), Bun.deepEquals(a, b, true, true) and the node:assert modes keep their code.
  2. Two objects of one Structure that lists no constructor: same class. A Structure is JavaScriptCore's hidden class: the own property names of an object, and its prototype. A type that makes up properties is excluded: a Proxy, a module namespace, process.env.
  3. Two arrays: same class, as in Jest.
  4. Two objects with one prototype and no own constructor: same class. They read constructor from one chain, so the step reads nothing. An object of compiled code (--aot) keeps own properties out of its Structure, so the step asks the object.
  5. A Map against a Set, a Date against another object, an array against an object that is no array: unequal at once. The walker gives these pairs that answer whatever their classes are.
  6. A function, a Proxy or a global object on either side: the step does not type the pair.
  7. Otherwise the step reads both constructor values with a VMInquiry slot, which runs no script. The two values give one class in three cases:
    • They are identical.
    • They are built-in functions of two realms with one name.
    • Each is an own constructor that is no function, and the prototypes are equal. This is a constructor key of plain data.
  8. Where only a getter or a Proxy in the prototype chain can answer, the class names decide.
  9. The class name comparison at the end of the walker stays as it is on main.

What breaks

test/js/bun/bun-object/deep-equals.test.ts holds 332 pairs. 128 of them change, all from equal to unequal. toEqual, Bun.deepEquals(a, b) and util.isDeepStrictEqual do not change for any pair.

  • An instance of a subclass against an instance of its base. The tests cover Map, Set, Date, RegExp, Error, ArrayBuffer, SharedArrayBuffer, 12 typed arrays, 8 Temporal classes, URL and Headers. So rejects.toStrictEqual(new NotFound("x")) no longer passes for a plain Error.
  • A Buffer against a Uint8Array. These return a Buffer: fs.readFileSync(), the Bun.zstd* functions, CryptoHasher#digest(), Bun.spawnSync().stdout, the stdout of a shell result, Database#serialize(), Archive#bytes(), redis.getBuffer().
  • These return a Uint8Array: Bun.gzipSync() and its siblings, Bun.sha(), Response#bytes(), Bun.file().bytes(), TextEncoder#encode(), a bun:sqlite BLOB, and structuredClone() of a Buffer.
  • A null-prototype object against a literal. Such results of JavaScript and Node: regex groups, querystring.parse(), Object.groupBy(), util.parseArgs().values. Such results of Bun: every map reply of Bun.redis such as hgetall(), Bun.Cookie#toJSON(), and req.params of a route with no named parameter. { __proto__: null, ... } as the expected value passes.
  • Two classes with one name. A package whose exports name one file for import and another for require is loaded twice. So is a package that is installed twice. Node loads two copies too. A package with main and module stays one class.
  • With --environment=jsdom or happy-dom, the global URL is a subclass of Bun's. With jsdom, so is Request. So URL.parse(x) against new URL(x) is unequal, and request.clone() against the request.
  • .not.toStrictEqual between a Buffer and a Uint8Array with the same bytes now passes.
  • Bun.deepEquals(a, b, true) outside a test returns false for each of these pairs and throws nothing. An example is structuredClone(buffer) against the buffer. The two-argument form does not change.
  • toEqual passes for every pair above.

A suite can take a pair for equal again with a tester in a preload. This one is for a Buffer and a Uint8Array:

expect.addEqualityTesters([
  (a, b) =>
    a instanceof Uint8Array && b instanceof Uint8Array && a.constructor !== b.constructor
      ? Buffer.compare(a, b) === 0
      : undefined,
]);

What stays equal

  • Two instances of one class. A class whose prototype.constructor is deleted, against its base. MyArray.from([1]) against [1].
  • An instance with an own constructor that is the base class. @sinonjs/fake-timers gives its Date such a property.
  • A built-in or a plain object of another realm against one of the main realm. The tests use a node:vm context, and jsdom and happy-dom with and without --isolate.
  • On main, an instance of a class with no name equals a literal in plain Bun, but not under --aot. Both are unequal now.

Where Bun differs from Jest 30.5.2

Seven groups of 19 pairs are tests in deep-equals.test.ts, because the class step decides them:

  • An own constructor key that is no function is data. JSON.parse('{"constructor":{"a":1}}') twice is equal here and in Node, and unequal in Jest.
  • A constructor getter is not called. A class whose prototype has such a getter goes by its class name. So a Node system error still equals an Error with the same name and message.
  • A Proxy in the prototype chain is not asked either. The class name is then that of the built-in type. One pair changes from main: a Map whose prototype is a Proxy of MyMap.prototype no longer equals a MyMap.
  • Bun compares the cause and the own properties of an Error, and the own properties of a typed array. Jest does not.
  • Two built-in functions of one realm are two classes. A mock function of Bun is native code, so Jest's rule makes two mocks one class.

Two groups of 5 pairs answer as on main:

Not in the tests, and as on main:

Earlier decisions that this change reverses

Measured (release x64 builds of main at 3f3a9ff, without and with this change)

User-mode instructions of one Bun.deepEquals(a, b, true) call, counted by single steps in gdb. The pairs are the strict rows that this change adds to bench/snippets/deep-equals.js.

pair main this PR change
{ a, b } twice 2,082 2,101 +19
{ a, b } and { b, a } 2,415 2,467 +52
object literal and JSON.parse() 2,645 2,697 +52
8 keys, one Structure 3,088 3,191 +103
8 keys, literal and JSON.parse() 4,266 4,486 +220
[1, 2, 3] twice 1,573 1,585 +12
array literal and computed array 1,573 1,578 +5
arrays of 10 { id, name } 23,229 23,387 +158
instances of one class 1,770 1,789 +19
Date 338 361 +23
RegExp 449 472 +23
Map 655 678 +23
Set 575 598 +23
Uint8Array 473 496 +23
Error 6,243 6,352 +109
Sets of 7 values of 7 kinds 20,472 19,177 -1,295
Maps with such keys 20,863 19,514 -1,349
two Dates that own constructor 338 1,041 +703
such a Date and a Date 338 920 +582
Date of another realm 338 1,109 +771
Uint8Array of another realm 473 1,317 +844
Map of another realm 655 1,442 +787
{ a, b } of another realm 2,415 3,142 +727
instance and object literal (unequal on both) 1,591 863 -728
Map and Set (unequal on both) 290 276 -14
subclass of Set and Set (equal on main) 575 873 +298
  • A Structure keeps a one-word filter of the names it has seen. With up to 3 keys in these runs, the filter rules out constructor. With more keys, the step does one property-table lookup for each Structure.
  • The last equal rows need the read of constructor. That happens for a value of another realm and for an own constructor. With jsdom the global typed arrays are the window's, so a Uint8Array that Bun makes is of another realm there.
  • Unequal Sets of 100 Dates: +23 for each candidate pair, 6.0% in total.
  • Pairs of typed objects under --aot: 3,216 to 3,462 for one field, 5,601 to 5,847 for 8 fields. bun build --compile --aot and bun test --aot give the same numbers. The step asks each compiled object with AOT::PropertyWalk::getDirect.
  • toEqual, node:assert and the 4-argument mode: 0. 5 of the 7 Bun__deepEquals instantiations and the 8 per-type functions are identical after address normalisation.
  • A strict { a: 1 } pair makes 2 allocations and 2 frees on main and here (gdb breakpoint counts).
  • A subclass of Set with 2,000 objects against a Set with equal members: main enters the walker 4,000,001 times. Then it answers equal. This branch enters the walker once.
  • The class step enters user code 0 times. A counting constructor getter and counting Proxy traps give the same counts as on main.
  • Release text: 140,286,972 to 140,291,836 bytes.
  • File size of each release target against main, from the size check of the build: +0.0 to +16.0 KB. linux-x64 +4.0 KB, darwin-x64 +16.0 KB, windows-x64 +1.0 KB, windows-aarch64 +9.5 KB, linux-aarch64 and darwin-aarch64 0.

Tests

  • expect.test.js: 40 new tests, 38 fail on main. 37 are not specific to Bun and pass on Jest 30.5.2 and Vitest 4.1.12.
  • deep-equals.test.ts: 308 pairs answer as equals of @jest/expect-utils 30.5.2 with the testers of its toStrictEqual. Main answers so for 186 of them. test/package.json pins the package. One more test loads a package twice.
  • test/aot/parallel/typed-objects/deep-equality-of-classes and test-aot-deep-equality.test.ts: 75 pairs answer under --aot as in plain Bun, and a snapshot holds the answers. Both fail on main.
  • test-environment.test.ts: strict rows under jsdom and happy-dom, with and without --isolate.
  • dom-nodes.test.ts and dom-nodes-jest.test.js: nodes behind a Proxy, which the step does not type. Jest 30.5.2 passes the second file as it is.
  • zstd.test.ts and valkey/unit/buffer-operations.test.ts: 8 assertions compared a Buffer with a Uint8Array. They now expect a Buffer. The type of getBuffer() says Buffer now, as its docs do.
  • 307 test files under test/ ran on the release build of this branch. They are every *.test.* file there that calls toStrictEqual, and the tests of the runner. The 9 files that fail there fail the same tests on main.
  • The 119 files that call toStrictEqual also ran on the debug build, with the same result. The elysia 1.4.28 suite: no change.
  • BUN_JSC_validateExceptionChecks=1 on the debug build: no report.

Other pull requests on these lines

Not in this change

  • For two objects with one prototype and no own constructor, the class name comparison at the end repeats work. A pair of plain objects saves about 1,200 instructions without it. That needs its own tests.
  • Cookie#toJSON() returns a null-prototype object, and CookieMap#toJSON() does not. The types of Archive#bytes() and of the data of a Terminal say Uint8Array for a Buffer.
  • toStrictEqual of a Map whose prototype is Set.prototype now fails. The printer then throws Set operation called on non-Set object, as toBe does on main.

For a maintainer to decide

  • Whether to ship the break with no flag, as a listed behaviour change. A text for that list: "toStrictEqual() and Bun.deepEquals(a, b, true) compare the classes of two objects, as in Jest. A Buffer does not equal a Uint8Array, an object with no prototype does not equal an object literal, and an instance of a subclass does not equal an instance of its base. toEqual() does not change."
  • Bun.deepEquals(a, b, true) takes the same rule as toStrictEqual, because the docs call them one comparison. An enableAsymmetricMatchers condition at the call keeps it on names alone.

no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/util/zstd.test.ts, test/js/bun/bun-object/deep-equals.test.ts

… per-type comparison

`expect(new MyMap()).toStrictEqual(new Map())` passed, where MyMap extends
Map. Bun__deepEquals compared class names only after the comparison for
Map, Set, Date, RegExp, Error, ArrayBuffer, the typed arrays and the
Temporal classes had answered.

toStrictEqual() and Bun.deepEquals(a, b, true) now compare classes first,
with the rule of Jest's typeEquality: two objects have one class when
they have one `constructor`, when both are arrays, or when their
constructors are the same built-in of two realms. The step runs no
script. Where only a getter or a Proxy in the prototype chain can answer,
the class names decide. An own `constructor` that is no function is a key
of the data.

Two objects with one prototype and no own `constructor` need no read.
toEqual(), Bun.deepEquals(a, b), the fourth argument of Bun.deepEquals()
and the node:assert modes do not change.

A Buffer is not a Uint8Array for these two: the type of
RedisClient#getBuffer() now says Buffer, which is what it returns.
@robobun

robobun commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:50 AM PT - Nov 3rd, 2026

❌ @robobun, your commit 442a2e9 has 40 failures in Build #137216 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47837

That installs a local version of the PR into your bun-47837 executable, so you can run:

bun-47837 --bun

@robobun

robobun commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

  • Reproduced with the Bun 1.4.2 release and with a debug build of main: bun test test/js/bun/test/expect.test.js -t "compares classes" fails 38 of the 40 new tests. A build of this branch passes all 40.
  • CI: build 137216 finished red for 40 test files. All are under test/aot/, plus test/bundler/compile-aot-size.test.ts. The same 40 files are red in build 137162 of main at 3bf1a06, which this branch merges. No test file that this change touches fails on any lane. That includes the ASAN, Windows and macOS lanes.
  • This is a draft. It waits for bun:test: say when both values print the same, and when printing stopped, instead of an empty diff #37764, and for a maintainer's decision on the behaviour change.

@github-actions github-actions Bot added the claude label Nov 3, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant