Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,6 @@ jobs:

- run: pnpm install

- run: pnpm build

- run: npm publish --provenance
148 changes: 89 additions & 59 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,90 +1,78 @@
import { isObject, isRegex } from './helpers';
import type { AnyObject } from './types';

type Seen = WeakMap<object, WeakSet<object>>;

/**
* Internal comparison with circular reference tracking.
* Compare two objects for deep equality.
*/
function compareValues(left: unknown, right: unknown, seen: Seen): boolean {
if (left === right) {
function compareObjects(left: AnyObject, right: AnyObject, seen: Seen): boolean {
if (hasSeen(seen, left, right)) {
return true;
}

if (left && isObject(left) && right && isObject(right)) {
// Check for circular reference
if (hasSeen(seen, left as object, right as object)) {
return true;
}

markSeen(seen, left as object, right as object);
markSeen(seen, left, right);

if (left.constructor !== right.constructor) {
return false;
}

if (Array.isArray(left) && Array.isArray(right)) {
return equalArray(left, right, seen);
}

if (left instanceof Map && right instanceof Map) {
return equalMap(left, right, seen);
}

if (left instanceof Set && right instanceof Set) {
return equalSet(left, right);
}
if (left.constructor !== right.constructor) {
return false;
}

if (ArrayBuffer.isView(left) && ArrayBuffer.isView(right)) {
return equalArrayBuffer(left, right);
}
if (Array.isArray(left) && Array.isArray(right)) {
return equalArray(left, right, seen);
}

if (isRegex(left) && isRegex(right)) {
return left.source === right.source && left.flags === right.flags;
}
if (left instanceof Map && right instanceof Map) {
return equalMap(left, right, seen);
}

if (left.valueOf !== Object.prototype.valueOf) {
return left.valueOf() === right.valueOf();
}
if (left instanceof Set && right instanceof Set) {
return equalSet(left, right);
}

if (left.toString !== Object.prototype.toString) {
return left.toString() === right.toString();
}
if (left instanceof WeakMap || left instanceof WeakSet) {

Copilot AI Feb 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WeakMap/WeakSet check is inconsistent with the pattern used for other types in this function. All other instanceof checks (Map, Set, Error, etc.) verify both left and right parameters, but this check only verifies left. While technically correct due to the constructor equality check on line 16, this inconsistency makes the code harder to understand and maintain. Consider changing to if ((left instanceof WeakMap && right instanceof WeakMap) || (left instanceof WeakSet && right instanceof WeakSet)) to match the established pattern.

Suggested change
if (left instanceof WeakMap || left instanceof WeakSet) {
if ((left instanceof WeakMap && right instanceof WeakMap) || (left instanceof WeakSet && right instanceof WeakSet)) {

Copilot uses AI. Check for mistakes.
return false;
}

const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if (ArrayBuffer.isView(left) && ArrayBuffer.isView(right)) {
return equalArrayBuffer(left, right);
}

if (leftKeys.length !== rightKeys.length) {
return false;
}
if (isRegex(left) && isRegex(right)) {
return left.source === right.source && left.flags === right.flags;
}

for (let index = leftKeys.length; index-- !== 0; ) {
if (!Object.prototype.hasOwnProperty.call(right, leftKeys[index])) {
return false;
}
}
if (left instanceof Error && right instanceof Error) {
return equalError(left, right, seen);
}

for (let index = leftKeys.length; index-- !== 0; ) {
const key = leftKeys[index];
if (left.valueOf !== Object.prototype.valueOf) {
return left.valueOf() === right.valueOf();
}

// React-specific: avoid comparing React elements' _owner
// which contains different fiber references between renders
if (key === '_owner' && left.$$typeof) {
continue;
}
if (left.toString !== Object.prototype.toString) {
return left.toString() === right.toString();
}

if (!compareValues(left[key], right[key], seen)) {
return false;
}
}
return equalPlainObject(left, right, seen);
}

/**
* Internal comparison with circular reference tracking.
*/
function compareValues(left: unknown, right: unknown, seen: Seen): boolean {
if (left === right) {
return true;
}

if (Number.isNaN(left) && Number.isNaN(right)) {
return true;
}

return left === right;
if (!left || !isObject(left) || !right || !isObject(right)) {
return false;
}

return compareObjects(left, right, seen);
}

/**
Expand Down Expand Up @@ -128,6 +116,17 @@ function equalArrayBuffer(left: ArrayBufferView, right: ArrayBufferView) {
return true;
}

/**
* Check if errors are equal.
*/
function equalError(left: Error, right: Error, seen: Seen): boolean {
return (
left.message === right.message &&
left.name === right.name &&
compareValues(left.cause, right.cause, seen)
);
}
Comment on lines +119 to +128

Copilot AI Feb 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The equalError function only compares the message, name, and cause properties of Error objects, ignoring any custom properties that may have been added to the Error instances. While this appears to be an intentional design choice to treat Errors as value objects, it creates inconsistent behavior compared to how plain objects are handled (where all properties are compared). Consider documenting this behavior in the function's JSDoc comment to make the design decision explicit.

Copilot uses AI. Check for mistakes.

/**
* Check if maps are equal.
*/
Expand All @@ -151,6 +150,37 @@ function equalMap(left: Map<unknown, unknown>, right: Map<unknown, unknown>, see
return true;
}

/**
* Check if plain objects are equal.
*/
function equalPlainObject(left: AnyObject, right: AnyObject, seen: Seen): boolean {
const leftKeys = Object.keys(left);

if (leftKeys.length !== Object.keys(right).length) {
return false;
}

for (let index = leftKeys.length; index-- !== 0; ) {
if (!Object.prototype.hasOwnProperty.call(right, leftKeys[index])) {
return false;
}
}

for (let index = leftKeys.length; index-- !== 0; ) {
const key = leftKeys[index];

if (key === '_owner' && left.$$typeof) {
continue;
}

if (!compareValues(left[key], right[key], seen)) {
return false;
}
}

return true;
}

/**
* Check if sets are equal.
*/
Expand Down
43 changes: 43 additions & 0 deletions test/objects.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,49 @@
import equal from '../src';

describe('objects', () => {
describe('errors', () => {
it.each([
{
description: 'errors with same message are equal',
value1: new Error('a'),
value2: new Error('a'),
expected: true,
},
{
description: 'errors with different message are not equal',
value1: new Error('a'),
value2: new Error('b'),
expected: false,
},
{
description: 'TypeError and Error with same message are not equal',
value1: new TypeError('a'),
value2: new Error('a'),
expected: false,
},
{
description: 'errors with same cause are equal',
value1: new Error('a', { cause: 'x' }),
value2: new Error('a', { cause: 'x' }),
expected: true,
},
{
description: 'errors with different cause are not equal',
value1: new Error('a', { cause: 'x' }),
value2: new Error('a', { cause: 'y' }),
expected: false,
},
{
description: 'errors with deep cause are equal',
value1: new Error('a', { cause: { n: 1 } }),
value2: new Error('a', { cause: { n: 1 } }),
expected: true,
},
])('$description', ({ expected, value1, value2 }) => {
expect(equal(value1, value2)).toBe(expected);
});
Comment on lines +5 to +44

Copilot AI Feb 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error comparison tests are missing a test case for when one error has a cause property and the other doesn't (e.g., new Error('a', { cause: 'x' }) vs new Error('a')). Adding this test case would improve coverage and verify that the comparison correctly handles this edge case.

Copilot uses AI. Check for mistakes.
});

describe('circular references', () => {
it('should handle self-referential objects', () => {
const a: any = { value: 1 };
Expand Down
23 changes: 23 additions & 0 deletions test/weakmap-weakset.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import equal from '../src';

describe('WeakMap and WeakSet', () => {
it('WeakMaps are never equal (not comparable)', () => {
expect(equal(new WeakMap(), new WeakMap())).toBe(false);
});

it('WeakSets are never equal (not comparable)', () => {
expect(equal(new WeakSet(), new WeakSet())).toBe(false);
});

it('same WeakMap reference is equal', () => {
const wm = new WeakMap();

expect(equal(wm, wm)).toBe(true);
});

it('same WeakSet reference is equal', () => {
const ws = new WeakSet();

expect(equal(ws, ws)).toBe(true);
});
});
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"extends": "@gilbarbara/tsconfig",
"compilerOptions": {
"noEmit": true,
"target": "ES2020"
"target": "ES2022"
},
"include": ["src/**/*"]
}
Loading