From 74d91a242024e8e8cc4556c710f94e8adfa050cc Mon Sep 17 00:00:00 2001 From: Gil Barbara Date: Tue, 3 Feb 2026 23:00:34 -0300 Subject: [PATCH 1/3] Add equality checks for errors and WeakMap/WeakSet --- src/index.ts | 13 +++++++++++ test/objects.spec.ts | 43 ++++++++++++++++++++++++++++++++++++ test/weakmap-weakset.spec.ts | 23 +++++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 test/weakmap-weakset.spec.ts diff --git a/src/index.ts b/src/index.ts index ad547f4..277891d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,11 @@ function compareValues(left: unknown, right: unknown, seen: Seen): boolean { return equalSet(left, right); } + // WeakMap and WeakSet cannot be compared (not iterable) + if (left instanceof WeakMap || left instanceof WeakSet) { + return false; + } + if (ArrayBuffer.isView(left) && ArrayBuffer.isView(right)) { return equalArrayBuffer(left, right); } @@ -42,6 +47,14 @@ function compareValues(left: unknown, right: unknown, seen: Seen): boolean { return left.source === right.source && left.flags === right.flags; } + if (left instanceof Error && right instanceof Error) { + return ( + left.message === right.message && + left.name === right.name && + compareValues(left.cause, right.cause, seen) + ); + } + if (left.valueOf !== Object.prototype.valueOf) { return left.valueOf() === right.valueOf(); } diff --git a/test/objects.spec.ts b/test/objects.spec.ts index 29360d8..b4ba0f8 100644 --- a/test/objects.spec.ts +++ b/test/objects.spec.ts @@ -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); + }); + }); + describe('circular references', () => { it('should handle self-referential objects', () => { const a: any = { value: 1 }; diff --git a/test/weakmap-weakset.spec.ts b/test/weakmap-weakset.spec.ts new file mode 100644 index 0000000..12bad36 --- /dev/null +++ b/test/weakmap-weakset.spec.ts @@ -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); + }); +}); From 467e81857be830929c6a0367c0f7549763fbaed0 Mon Sep 17 00:00:00 2001 From: Gil Barbara Date: Tue, 3 Feb 2026 23:01:50 -0300 Subject: [PATCH 2/3] Fix CI build for publish --- .github/workflows/ci.yml | 2 ++ tsconfig.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce79e3d..e85f5bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,4 +64,6 @@ jobs: - run: pnpm install + - run: pnpm build + - run: npm publish --provenance diff --git a/tsconfig.json b/tsconfig.json index 38584af..a29a11b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "extends": "@gilbarbara/tsconfig", "compilerOptions": { "noEmit": true, - "target": "ES2020" + "target": "ES2022" }, "include": ["src/**/*"] } From 047cab6fca9705a263317078aad0d5c5c5f5afa3 Mon Sep 17 00:00:00 2001 From: Gil Barbara Date: Tue, 3 Feb 2026 23:07:35 -0300 Subject: [PATCH 3/3] Refactor main functions for clarity --- src/index.ts | 161 ++++++++++++++++++++++++++++----------------------- 1 file changed, 89 insertions(+), 72 deletions(-) diff --git a/src/index.ts b/src/index.ts index 277891d..60d9358 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,95 +1,66 @@ import { isObject, isRegex } from './helpers'; +import type { AnyObject } from './types'; type Seen = WeakMap>; /** - * 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); - - 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); - } + markSeen(seen, left, right); - // WeakMap and WeakSet cannot be compared (not iterable) - if (left instanceof WeakMap || left instanceof WeakSet) { - return false; - } - - if (ArrayBuffer.isView(left) && ArrayBuffer.isView(right)) { - return equalArrayBuffer(left, right); - } + if (left.constructor !== right.constructor) { + return false; + } - if (isRegex(left) && isRegex(right)) { - return left.source === right.source && left.flags === right.flags; - } + if (Array.isArray(left) && Array.isArray(right)) { + return equalArray(left, right, seen); + } - if (left instanceof Error && right instanceof Error) { - return ( - left.message === right.message && - left.name === right.name && - compareValues(left.cause, right.cause, seen) - ); - } + 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) { + 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; } @@ -97,7 +68,11 @@ function compareValues(left: unknown, right: unknown, seen: Seen): boolean { return true; } - return left === right; + if (!left || !isObject(left) || !right || !isObject(right)) { + return false; + } + + return compareObjects(left, right, seen); } /** @@ -141,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) + ); +} + /** * Check if maps are equal. */ @@ -164,6 +150,37 @@ function equalMap(left: Map, right: Map, 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. */