JSON.equals(x, y)

Polyfill:

JSON.equals ??= (x, y) => JSON.stringify(x) === JSON.stringify(y);

JSON.equals([1, "a"], [1, JSON.rawJSON('"a"')]) // true

Does what it says on the tin. "Would the JSON representation of these two values match?" Can be optimized to avoid allocating a potentially large string buffer.

What is the problem this is solving? Solutions generally don't come until stage 1-2.

Specifically, the problem this is solving is the common usage of

if (JSON.stringify(x) === JSON.stringify(y))

which makes me grit my teeth every time I see it, especially on collections of unbounded size. (Perhaps the problem being solved is my dental health.)

There is no standard deep-equality function in standard ECMAScript. The right way to solve the deep-equality problem is probably to write object- and class-specific equality tests, but in the absence of that, people can and do just stringify both objects and see if the string matches.

I understand some of the difficulting in specifying a standard deep-equality function. What exactly does "equality" mean? Which members get reference-checked, and which get value-checked? Does a difference in private fields break equality? Can an object override its native equality behavior? Are object cycles allowed? All extremely important questions that absolutely need to get answered before we can have something like an Object.equals.

And in the meantime, people will continue to write the following, found in a commit from 2022:

// Why is this something i have to write
equals(thing1, thing2) {
  return JSON.stringify(thing1) === JSON.stringify(thing2);
}

And, despite the steadily-decreasing tread depth of my teeth, I can't argue that there's one major benefit from comparing things this way: if people have written toJSON() methods on their classes, then it probably means that those methods will return all the significant data, i.e. all the data that probably wants to be compared if you're trying to tell if something has changed.

I would never suggest that a default of some hypothetical generic Object.equals() should respect toJSON() methods - I'd find that behavior very surprising, in a bad way. But, in cases where we do want to compare "the exported data of these two objects", like, say, if you're trying to decide whether you need to autosave a game, or whether your cache needs refreshing, then a JSON.equals() method would be exactly what I want. "Is the hypothetical JSON output of this object equal to the hypothetical JSON output of this other one? No, please don't allocate two 10MB string buffers, all I want is the yes/no." It might also want a version that compares an object against a JSON string, for when you've kept your previous save-export around.

Sorry for the brevity of my original post! I have difficulty knowing when I need to elaborate vs when I need to be succinct, and I erred too far on the latter side this time :sweat_smile:

We have https://github.com/tc39/proposal-array-equality and the closer match, the withdrawn https://github.com/sebmarkbage/ecmascript-shallow-equal - it's worth reading the notes for the latter proposal in particular.

2 Likes

Cool, I'll take a look at those, thanks! Doesn't seem like it'll address the JSON.stringify-equality pattern, but I suppose I'll just have to hope that engines know enough to special-case that at the AST level

I suppose another solution to the problem of "the only way to get a comparable value out of a deep structure forces you to allocate unbounded amounts of memory" would be something like JSON.checksum(object), which would give you the equivalent of running some sort of digest on whatever the result of JSON.stringify() would be, and then you could compare those values.

Given that ECMAScript doesn't really do checksums, though (there's no way to checksum a string, so how would you compare against a static JSON string?), that feels like it would be an extremely unusual solution in this case.

I suppose it's too much to hope for a structuredEqual function, huh? structuredClone() addressed the other case where people were abusing JSON round-trips, namely

JSON.parse(JSON.stringify(value))

But at least in that context, you're trying to allocate a new object. Sure, it might require a round-trip to a 10MB JSON string, but you're left with a 5MB object afterward. In the comparison case, you're not actually trying to allocate anything, you're just getting a true/false out of it.

None of this is hypothetical, by the way. The idle game I've taken over maintenance of has a nasty habit of bogging down unpredictably, and the bug reports I've gotten from people whose computers froze up after they were left running overnight sound a lot more like memory pressure than anything else. I'm not saying that the stringify-equality issue is the sole culprit here (far from it tbh, I'm working on the game's memory footprint but it's slow progress) but it's one I can't easily avoid unless I'm willing to re-implement the entirety of JSON.stringify in userland just so I can change the final emit to a compare.

I'll mention that

function equals(thing1, thing2) {
  return JSON.stringify(thing1) === JSON.stringify(thing2);
}

Isn't very reliable - it'll also compare the order of object keys, which you wouldn't want to have happen in a comparison algorithm.

3 Likes

Unless and until you do want to compare the order of object keys, of course. :joy: That goes toward my point of "it's hard to define what a deep equality function should mean, exactly", and that's why I suggested JSON.equal - for when you're literally trying to detect a change in the JSON representation of an object. Like, if I'm trying to decide whether I should save this object off to LocalStorage (not this exact use case, the 10MB JSON doesn't fit in localStorage - this is an in-memory cache) then "has my serialized representation changed" is exactly the question I want answered.

But that's not even at issue in my case, because here the code is detecting changes in a very large, very deep, but fixed-structure dataset. The keys aren't at risk of changing, all that matters in this case is whether any of the values have changed.

The reason I haven't replaced the equals function in the code is that I can't. I've tried iterating the objects by hand, but given that there are a lot of map-objects (not Map objects but the old kind, and not even the fancy null-prototype ones, either) it has to use some code paths that just crawl on some engines.

JSON.stringify, on the other hand, walks an entire object tree in native engine code, which makes it significantly faster, in this case, than any other solution. The only other native function I can think of off the top of my head that will walk an entire object tree for you is structuredClone, and that's not precisely useful for this case.

So, sadly, the equals function has to stay until I can refactor out this entire section of the codebase and remove the comparison entirely, and until I do, I have to leave this ticking memory bomb of an equality function running on all my players' computers. I do admit it does make me feel some kinship with the original author - I've been tempted to add my own comment reading "Why is this something I have to leave in place" :sweat_smile:

This discussion inspired me to create this post - Native deep-clone algorithm? - I don't directly talk about deep equality in there - I was originally going to, but realized I was already getting too long-winded, so decided not to go there.

But I do agree that the language ought to have some sort of deep comparison algorithm provided, as it is a fairly common task, and it takes a fair amount of boilerplate to do by hand.

1 Like