Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow comparing special/tagged objects #120

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions __tests__/comparator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const STANDARD_COMPARATOR_OPTIONS = {
areRegExpsEqual,
areSetsEqual,
areTypedArraysEqual,
unknownTagComparators: {},
Copy link
Owner

Choose a reason for hiding this comment

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

Recommendation: Change to a shared callback areUnhandledObjectsEqual which handles all unhandled tags.

In the description you mention that you wanted to go with an object of keys to avoid potential refactor pitfalls with version changes, but in reality the refactor work would still be the same. There really isn't a scale issue here since practical usage doesn't involve having a ton of these custom tags for comparison, and it would also allow for an escape hatch if someone wanted to include known-but-unhandled object types in their comparison, such as Promise, WeakMap / WeakSet, etc.

};
const CIRCULAR_COMPARATOR_OPTIONS = {
...STANDARD_COMPARATOR_OPTIONS,
Expand Down
32 changes: 32 additions & 0 deletions __tests__/recipes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,38 @@ describe('recipes', () => {
expect(deepEqual(a, c)).toBe(false);
});
});

describe("special-objects", () => {
it("should call unknownTagComparators", () => {
// Emulate something like a private property
const hiddenData = new WeakMap<Thing, number>();
const tag = "Thing";

class Thing {
[Symbol.toStringTag] = tag
constructor(value: number) {
hiddenData.set(this, value);
}
equals(other: Thing) {
return hiddenData.get(this) === hiddenData.get(other);
}
}

const deepEqual = createCustomEqual({
createCustomConfig: () => ({
unknownTagComparators: {
[tag]: (a, b) => a instanceof Thing && b instanceof Thing && a.equals(b)
}
}),
});

const a = new Thing(1);
const b = new Thing(1);
const c = new Thing(2);
expect(deepEqual(a, b)).toBe(true);
expect(deepEqual(a, c)).toBe(false);
});
});
});

describe('createCustomCircularEqual', () => {
Expand Down
25 changes: 25 additions & 0 deletions recipes/special-objects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Handling special (built-in) objects

The `createCustomEqual` uses `@@toStringTag` to decide which well-known comparator to call. However, some values might not fit either of them. For example `WeakMap` is compared intentionally. Additionally, it's possible that new built-in objects are added to the language. Third parties might also add `@@toStringTag` to their objects.

For example the [TC39 Temporal spec](https://tc39.es/proposal-temporal/docs/) requires `@@toStringTag` being implemetend and the polyfills do that. Passing `areObjectsEqual` will not work in that scenario. Instead you'll have to register a handler for the tag instead.

```ts
import { createCustomEqual } from 'fast-equals';
import type { TypeEqualityComparator } from 'fast-equals';

const areZonedDateTimesEqual: TypeEqualityComparator<unknown, undefined> = (
a,
b,
) => a instanceof Temporal.ZonedDateTime
&& b instanceof Temporal.ZonedDateTime
&& a.equals(b);

const isSpecialObjectEqual = createCustomEqual({
createCustomConfig: () => ({
unknownTagComparators: {
"Temporal.ZonedDateTime", areZonedDateTimesEqual,
}
}),
});
```
6 changes: 5 additions & 1 deletion src/comparator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const { assign } = Object;
const getTag = Object.prototype.toString.call.bind(
Object.prototype.toString,
) as (a: object) => string;
const getShortTag = (a: object): string | undefined => a != null ? (a as any)[Symbol.toStringTag] : undefined;
Copy link
Owner

Choose a reason for hiding this comment

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

Issue: Since the use of Symbol.toStringTag updates the output of Object.prototype.toString.call(), it can be used directly.


interface CreateIsEqualOptions<Meta> {
circular: boolean;
Expand All @@ -59,6 +60,7 @@ export function createEqualityComparator<Meta>({
areRegExpsEqual,
areSetsEqual,
areTypedArraysEqual,
unknownTagComparators,
}: ComparatorConfig<Meta>): EqualityComparator<Meta> {
/**
* compare the value of the two objects and return true if they are equivalent in values
Expand Down Expand Up @@ -194,7 +196,8 @@ export function createEqualityComparator<Meta>({
// equality is (`Error`, etc.), the subjective decision is to be conservative and strictly compare.
// In all cases, these decisions should be reevaluated based on changes to the language and
// common development practices.
return false;
const unknownTagComparator = unknownTagComparators[getShortTag(a) ?? tag];
Copy link
Owner

Choose a reason for hiding this comment

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

Request: I know this is a bit more concise, but I request that the explicit if check is done before this.

if (unknownTagComparator) {
  return unknownTagComparator(a, b, state);
}

// Massive comment explaining ultimate fallback
...
return false;

It keeps the code comments applicable, and also allows for smaller diffs in the future if it was preferred to be bumped in priority instead of the ultimate fallback.

return unknownTagComparator ? unknownTagComparator(a, b, state) : false;
};
}

Expand Down Expand Up @@ -225,6 +228,7 @@ export function createEqualityComparatorConfig<Meta>({
areTypedArraysEqual: strict
? areObjectsEqualStrictDefault
: areTypedArraysEqual,
unknownTagComparators: {},
};

if (createCustomConfig) {
Expand Down
7 changes: 7 additions & 0 deletions src/internalTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ export interface ComparatorConfig<Meta> {
* additional properties added to the typed array.
*/
areTypedArraysEqual: TypeEqualityComparator<any, Meta>;
/**
* Whether two values with unknown `@@toStringTag` are equal in value. This comparator is
* called when no other comparator applies.
*
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/toStringTag
*/
unknownTagComparators: Partial<Record<string, TypeEqualityComparator<unknown, Meta>>>;
}

export type CreateCustomComparatorConfig<Meta> = (
Expand Down