Skip to content
34 changes: 26 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,19 @@ Single export: `isEqual(a, b)`.

```text
src/
is-equal.ts # Entire implementation (~132 lines, single exported function)
is-equal.ts # Entire implementation (~157 lines, single exported function)
is-equal.test.ts # Test runner — iterates fixture suites + edge case tests
comparison.benchmark.ts # Vitest benchmarks vs other libraries
fixtures/
tests.ts # Test case definitions (TestSuite[] with TestCase[])
benchmark.ts # Benchmark fixtures
```

Single-file library. All comparison logic lives in `src/is-equal.ts` as one recursive closure.
Single-file library. All comparison logic lives in `src/is-equal.ts` as one module-level recursive function.

## Code Conventions

- **Yarn 4** — `yarn@4.10.2`, node-modules linker
- **Yarn 4** — `yarn@4.12.0`, node-modules linker
- **TypeScript strict mode** — ESNext target, NodeNext modules
- **ESM only** — `"type": "module"`, `.js` extensions in imports
- **`@ver0/eslint-config`** — all `@typescript-eslint/no-unsafe-*` rules disabled (intentional, the core function uses `any`)
Expand All @@ -42,7 +42,7 @@ Single-file library. All comparison logic lives in `src/is-equal.ts` as one recu
- **Reverse iteration** in hot loops: `for (let i = length; i-- !== 0;)`
- **Self-comparison for NaN**: `a !== a && b !== b` (faster than `Number.isNaN`)
- **Prototype-based type checking**: `Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)` before constructor checks
- **Variable reuse**: TypedArray handling reassigns `a`/`b` to DataView — intentional, not a bug
- **Variable reuse**: parameters `a`/`b` may be reassigned locally — intentional, not a bug
- **Cached prototype methods**: `const {valueOf, toString} = Object.prototype` at module scope
- **eslint-disable comments** are intentional — `complexity` on the inner function, `no-self-compare` on NaN check

Expand All @@ -55,8 +55,26 @@ Single-file library. All comparison logic lives in `src/is-equal.ts` as one recu

## Gotchas

- **Sets use reference equality** — `new Set([{a:1}])` vs `new Set([{a:1}])` returns `false`. This is intentional (O(n) vs O(n²))
- **WeakMap per call** — created on every `isEqual()` invocation for circular reference tracking. Overhead is a deliberate trade-off for single-API simplicity
- **Sets use reference equality** — `new Set([{a:1}])` vs `new Set([{a:1}])` returns `false`. Intentional — respects the Set's own SameValueZero identity model rather than overriding it with deep comparison
- **Lazy WeakMap** — created only when recursion into objects/arrays/maps occurs. Primitives, Date, RegExp, Set, and TypedArray comparisons never allocate it
- **Stack depth** — recursive algorithm, deep nesting (>1000 levels) may cause stack overflow. Not mitigated; rare in practice
- **Custom classes** — compared via `valueOf()` then `toString()` fallback. Classes without these return false for different instances with same data
- **TypedArray byte comparison** — all TypedArrays converted to DataView. Handles endianness but adds conversion overhead
- **Symbol-keyed properties ignored** — symbols are designed as non-enumerable hidden identifiers for metadata (well-known symbols, `$$typeof`), not data-carrying properties. Comparing them as data would contradict their intended role
- **Custom classes** — compared via `valueOf()` then `toString()` fallback, only when both instances share the same function reference. Classes without these return false for different instances with same data
- **TypedArray byte comparison** — all TypedArrays and DataViews compared via Uint8Array over their `byteOffset`/`byteLength` slice. Byte-level comparison preserves NaN bit patterns

<git-commit-config>
<extra-instructions>
This project uses semantic-release with Angular preset (Conventional Commits).
Commit messages directly control automated versioning:

- `fix:` → patch release
- `feat:` → minor release
- `BREAKING CHANGE:` footer → major release

Breaking changes MUST use `BREAKING CHANGE:` (two words, uppercase) as a git
trailer in the commit footer. `BREAKING-CHANGE:` is also accepted.

Do NOT use `BREAKING:` alone or `!` in the subject — the Angular preset does
not detect these and the major version bump will be silently skipped.
</extra-instructions>
</git-commit-config>
134 changes: 126 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@ When comparing objects in JavaScript, the built-in equality operators (`==` and
equality, not structural equality. This means that two objects with the same properties and values will be considered
different if they're not the same instance.

Many deep equality solutions exist (like lodash's `isEqual`, fast-deep-equal, and others), but they often have
limitations:
Many deep equality solutions exist, but they often have limitations:

- Some don't handle circular references
- Some have inconsistent behavior with special values like NaN
Expand Down Expand Up @@ -76,27 +75,146 @@ isEqual(/abc/g, /abc/g); // true
isEqual(NaN, NaN); // true
```

### Comparison Behavior

#### Primitives and Special Values

Primitive values (numbers, strings, booleans, `undefined`, `null`) are compared using strict equality (`===`).

`NaN` is a special case — JavaScript's `===` operator considers `NaN !== NaN`, but `isEqual` treats two `NaN` values
as equal. This also applies to boxed NaN values: `isEqual(Object(NaN), Object(NaN))` returns `true`.

Boxed primitives (`new Number()`, `new String()`, `new Boolean()`) are compared by their underlying value using
`valueOf()`.

#### Objects

Two objects are first checked for matching prototypes — if their prototypes differ, they are not equal.

Then, both objects must have the same number of own enumerable string-keyed properties. Each property value is compared
recursively.

**Symbol-keyed properties are not compared.** Symbols are designed to be non-enumerable identifiers — they serve as
hidden metadata rather than data-carrying properties. Well-known symbols like `Symbol.iterator` define behavior, and
framework-specific symbols like `$$typeof` are internal markers. Comparing them as data would contradict their intended
role in the language.

#### Arrays

Arrays must have the same `length`. Each element is compared recursively by index.

#### Sets

A `Set` is an implementation of a mathematical set — an unordered collection of unique values. The fact that JavaScript
preserves insertion order during iteration is a convenience of the specification, not a defining characteristic. A Set
is not an array, and comparing it like one would be incorrect.

Sets must have the same `size`. Membership is checked using the Set's built-in `has()` method, which uses the
[SameValueZero](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Equality_comparisons_and_sameness#same-value-zero_equality)
algorithm. Order is irrelevant.

```javascript
// Order does not matter
isEqual(new Set([1, 2, 3]), new Set([3, 1, 2])); // true

// Object references — compared by identity, not structure
isEqual(new Set([{a: 1}]), new Set([{a: 1}])); // false
```

**Object elements are compared by reference, not by deep equality.** This follows from how the Set itself defines
uniqueness. A Set uses SameValueZero to determine whether a value already exists — you can add 100 structurally
identical objects and the Set will hold all 100 as distinct elements. Deep-comparing elements would impose an identity
model that contradicts the container's own semantics. `isEqual` respects the data structure's definition of membership
rather than overriding it.

#### Maps

A `Map` is a key-value collection where keys are identified by SameValueZero — the same identity model as Sets. Just
like with Sets, this means object keys are distinct references, not interchangeable structures. Two structurally
identical objects used as Map keys are two different keys as far as the Map is concerned.

Maps must have the same `size`. **Keys are compared by reference** using the Map's built-in `has()` method, while
**values are deep-compared** recursively.

```javascript
// Primitive keys — works as expected
isEqual(new Map([['a', {x: 1}]]), new Map([['a', {x: 1}]])); // true

// Object keys — compared by identity, not structure
const k1 = {id: 1};
const k2 = {id: 1};
isEqual(new Map([[k1, 'v']]), new Map([[k2, 'v']])); // false
```

The reasoning is the same as for Sets — `isEqual` respects the Map's own definition of key identity rather than
overriding it.

#### Dates and Regular Expressions

Dates are compared by their timestamp value (`getTime()`). Regular expressions are compared by their `source` and
`flags` properties.

#### TypedArrays, DataViews, and ArrayBuffers

All binary data types — `ArrayBuffer`, `SharedArrayBuffer`, `DataView`, and all TypedArray variants — are compared at
the byte level using `Uint8Array`.

For TypedArrays and DataViews, only the viewed slice is compared. The `byteOffset` and `byteLength` of the view are
respected, so two views into the same underlying buffer that cover different regions are correctly identified as
different.

Byte-level comparison has a correctness advantage: it preserves NaN bit patterns. A `Float64Array([NaN])` compared
element-by-element would fail because `NaN !== NaN`, but comparing the underlying bytes works correctly.

#### Custom Classes

When two objects share the same prototype but don't match any of the built-in types above, `isEqual` checks whether
the class defines a custom `valueOf()` or `toString()` method.

If `valueOf()` is present, differs from `Object.prototype.valueOf`, and **both instances share the same function
reference** (which is naturally true for prototype methods), the comparison is performed by calling `valueOf()` on each
instance and comparing the results. The same logic applies as a fallback to `toString()`.

This is a **terminal comparison** — if `valueOf()` or `toString()` is used, own properties are not checked afterward.
The rationale is that a class defining `valueOf()` is declaring "this is my primitive representation," and that
representation is the basis for equality.

Classes that don't define custom `valueOf()` or `toString()` fall through to property-by-property comparison, just like
plain objects.

#### Circular References

Circular and cross-references between objects are handled correctly. Visited object pairs are tracked during recursion
to detect cycles, so self-referential structures are compared without infinite loops.

### Performance

Check out the benchmarks by running `npm run benchmark` in the project directory.
Check out the benchmarks by running `yarn benchmark` in the project directory.

> While benchmark results may show this package isn't the fastest solution available, this is a deliberate trade-off.
> Unlike most deep equality libraries, this package supports circular reference detection. This requires tracking
> visited object pairs during recursion, which adds overhead on recursive structures (objects, arrays, maps).
>
> To minimize this cost, the tracking mechanism is lazily allocated — comparisons of leaf types like dates, regular
> expressions, sets, and typed arrays have zero tracking overhead. The cost is only paid when recursion actually occurs.
>
> The performance cost comes from supporting circular reference detection. Rather than splitting this into separate
> functions, I've prioritized simplicity in both the API design and implementation, eliminating the need for users to
> choose between different comparison functions.
> Rather than splitting into separate functions (one with cycle detection, one without), this package provides a single
> function that handles all cases. Simplicity in both the API and the implementation.

### Supported Types

- Primitive values (numbers, strings, booleans, undefined, null)
- NaN (correctly compared to be equal to itself)
- Boxed primitives (Number objects, String objects, Boolean objects)
- Plain objects
- Arrays
- Sets
- Maps
- Regular Expressions
- Date objects
- ArrayBuffers
- TypedArrays (Int8Array, Uint8Array, etc.)
- SharedArrayBuffers
- DataViews
- TypedArrays (Int8Array, Uint8Array, Float64Array, etc.)
- Objects with null prototypes
- Objects with custom `valueOf()` / `toString()`
- Any objects with circular references
97 changes: 93 additions & 4 deletions src/fixtures/tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,10 +264,10 @@ export const testCases: TestSuite[] = [
equal: false,
},
{
name: 'objects with different `valueOf` functions returning same values are equal',
name: 'objects with different `valueOf` functions returning same values are not equal',
value1: {valueOf: () => 'Hello world!'},
value2: {valueOf: () => 'Hello world!'},
equal: true,
equal: false,
},
{
name: 'objects with `valueOf` functions returning different values are not equal',
Expand All @@ -276,10 +276,10 @@ export const testCases: TestSuite[] = [
equal: false,
},
{
name: 'objects with different `toString` functions returning same values are equal',
name: 'objects with different `toString` functions returning same values are not equal',
value1: {toString: () => 'Hello world!'},
value2: {toString: () => 'Hello world!'},
equal: true,
equal: false,
},
{
name: 'objects with `toString` functions returning different values are not equal',
Expand Down Expand Up @@ -757,6 +757,24 @@ export const testCases: TestSuite[] = [
value2: new Int32Array([1, 2]),
equal: false,
},
{
name: 'equal subviews into different buffers',
value1: new Uint8Array(new ArrayBuffer(16), 4, 4),
value2: new Uint8Array(new ArrayBuffer(8), 0, 4),
equal: true,
},
{
name: 'not equal subviews at different offsets of the same-content buffer',
value1: (() => {
const buf = new Uint8Array([0, 0, 0, 0, 1, 2, 3, 4]);
return new Uint8Array(buf.buffer, 0, 4);
})(),
value2: (() => {
const buf = new Uint8Array([0, 0, 0, 0, 1, 2, 3, 4]);
return new Uint8Array(buf.buffer, 4, 4);
})(),
equal: false,
},
],
},
{
Expand Down Expand Up @@ -786,6 +804,26 @@ export const testCases: TestSuite[] = [
value2: new DataView(new Uint16Array([1, 2]).buffer),
equal: false,
},
{
name: 'equal DataView subviews into larger buffers',
value1: new DataView(new ArrayBuffer(16), 4, 4),
value2: new DataView(new ArrayBuffer(8), 0, 4),
equal: true,
},
{
name: 'not equal DataView subviews at different offsets',
value1: (() => {
const buf = new ArrayBuffer(8);
new Uint8Array(buf).set([0, 0, 0, 0, 1, 2, 3, 4]);
return new DataView(buf, 0, 4);
})(),
value2: (() => {
const buf = new ArrayBuffer(8);
new Uint8Array(buf).set([0, 0, 0, 0, 1, 2, 3, 4]);
return new DataView(buf, 4, 4);
})(),
equal: false,
},
],
},
{
Expand Down Expand Up @@ -817,6 +855,57 @@ export const testCases: TestSuite[] = [
},
],
},
{
name: 'Shared array buffers',
tests: [
{
name: 'two empty shared buffers',
value1: new SharedArrayBuffer(0),
value2: new SharedArrayBuffer(0),
equal: true,
},
{
name: 'equal shared buffers',
value1: (() => {
const buf = new SharedArrayBuffer(6);
new Uint16Array(buf).set([1, 2, 3]);
return buf;
})(),
value2: (() => {
const buf = new SharedArrayBuffer(6);
new Uint16Array(buf).set([1, 2, 3]);
return buf;
})(),
equal: true,
},
{
name: 'not equal shared buffers (different content)',
value1: (() => {
const buf = new SharedArrayBuffer(6);
new Uint16Array(buf).set([1, 2, 3]);
return buf;
})(),
value2: (() => {
const buf = new SharedArrayBuffer(6);
new Uint16Array(buf).set([1, 3, 3]);
return buf;
})(),
equal: false,
},
{
name: 'not equal shared buffers (different length)',
value1: new SharedArrayBuffer(6),
value2: new SharedArrayBuffer(4),
equal: false,
},
{
name: 'SharedArrayBuffer and ArrayBuffer are not equal',
value1: new SharedArrayBuffer(4),
value2: new ArrayBuffer(4),
equal: false,
},
],
},
{
name: 'Array buffers',
tests: [
Expand Down
20 changes: 20 additions & 0 deletions src/is-equal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,23 @@ it('should properly handle NaN', () => {
expect(isEqual(Number.NaN, 0)).toBe(false);
expect(isEqual(0, Number.NaN)).toBe(false);
});

it('should properly handle boxed NaN', () => {
// eslint-disable-next-line no-new-wrappers, unicorn/new-for-builtins
const a = new Number(Number.NaN);
// eslint-disable-next-line no-new-wrappers, unicorn/new-for-builtins
const b = new Number(Number.NaN);

expect(isEqual(a, b)).toBe(true);
expect(isEqual(a, a)).toBe(true);
});

it('should require matching valueOf/toString implementations', () => {
const sharedValueOf = () => 42;
const a = {valueOf: sharedValueOf};
const b = {valueOf: sharedValueOf};
const c = {valueOf: () => 42};

expect(isEqual(a, b)).toBe(true);
expect(isEqual(a, c)).toBe(false);
});
Loading
Loading