Implementing an identical JSON.stringify

# Michael Theriot (6 years ago)

JSON.stringify has unintuitive behavior regarding interal slots.

// Number with    [[NumberData]]
// "0"
JSON.stringify(Object(Number(0)));

// Number without [[NumberData]]
// "{}"
JSON.stringify(Reflect.construct(Object, [], Number));

// Number without [[NumberData]]
// Number with    [[StringData]]
// throws TypeError
JSON.stringify(Reflect.construct(String, [], Number));

// String with    [[StringData]]
// ""
JSON.stringify(Object(String()));

// String without [[StringData]]
// "{}"
JSON.stringify(Reflect.construct(Object, [], String));

// String without [[StringData]]
// String with    [[NumberData]]
// throws TypeError
JSON.stringify(Reflect.construct(Number, [], String));

// Object with    [[StringData]]
// "[object String]"
JSON.stringify(Reflect.construct(String, [], Object));

// Object with    [[NumberData]]
// null
JSON.stringify(Reflect.construct(Number, [], Object));

Weird, but not impossible to implement.

  • You can detect class with instanceof
  • You can detect internal slots by try-catching <class>.prototype.valueOf

e.g.

const isNumberInstance = (item) => item instanceof Number;

const isStringInstance = (item) => item instanceof String;

const hasNumberData = (item) => {
  try { Number.prototype.valueOf.call(item); } catch { return false; }
  return true;
};
const hasStringData = (item) => {
  try { String.prototype.valueOf.call(item); } catch { return false; }
  return true;
};

Since this relies on having a reference to the classes, it will not work with an instance of Number from another realm for example.

const item = Reflect.construct(iframe.contentWindow.String, [], iframe.contentWindow.Number);

iframe.contentWindow.Number !== Number;

I think this is related to Array.isArray. Is there an equivalent Number.isNumber? Or is this just something only JSON.stringify can do?

# Claude Pache (6 years ago)

Le 5 août 2018 à 00:16, Michael Theriot <michael.lee.theriot at gmail.com> a écrit :

JSON.stringify has unintuitive behavior regarding interal slots.

I don’t think that anything involving an object that has a [[StringData]] internal slot but has Number.prototype in its prototype chain could have an ”intuitive” behaviour...

I think this is related to Array.isArray. Is there an equivalent Number.isNumber? Or is this just something only JSON.stringify can do?

Try Number.prototype.valueOf.call(obj): it will throw a TypeError if and only if obj has no [[NumberData]] internal slot. Ditto for String, Boolean and Symbol.

# Michael Theriot (6 years ago)

Try Number.prototype.valueOf.call(obj): it will throw a TypeError if and only if obj has no [[NumberData]] internal slot. Ditto for String, Boolean and Symbol.

I already mention this and demonstrate why it is not sufficient in my example.

Reiterated plainly:

JSON.stringify(Reflect.construct(Number, [], Number)); // "0"
JSON.stringify(Reflect.construct(Number, [], String)); // TypeError
JSON.stringify(Reflect.construct(Number, [], Object)); // null

Even though both of these have [[NumberData]] internal slots, it also considers the type when throwing. Hence the question if you can type check cross-realm in a way that does not depend on internal slots.

# Darien Valentine (6 years ago)

Is this a question about how/if one could replicate its behavior in theory from ES code, or are you suggesting a change to the existing behavior for these exotic cases?

Assuming the former:

The relevant steps are here. The instanceof a value isn’t significant in this algorithm, just its slots (if it is an object) or its primitive type. So one can handle number and string by leveraging branded checks as you’ve shown — nothing more is needed than branded methods and typeof to manage those.

However it is ultimately not possible to (precisely) replicate because there is no possible brand test for [[BooleanData]].

Regarding cases like Reflect.construct(Number, [], String), the reason this throws is because the ToNumber algorithm calls ToPrimitive if its operand is an object. This in turn will call String.prototype.valueOf on the object which does not have [[StringData]]. There’s nothing funny going on, it’s just a weird effect in aggregate. You would just need to implement all the steps here — internal ops like ToNumber, ToPrimitive, etc. It’s not that it "considers type", but rather that these algorithms will call methods by specific names on objects they receive. String.prototype and Object.prototype both implement "valueOf", but only the former will fail the slot check.

Hence the question if you can type check cross-realm in a way that does not depend on internal slots.

There is no type to test. Exotic objects like Array are a special case — but the "type" of a number object is "an ordinary object with a [[NumberData]] slot". It’s really ducktyping all the way down.

# Claude Pache (6 years ago)

Le 5 août 2018 à 04:14, Darien Valentine <valentinium at gmail.com> a écrit :

However it is ultimately not possible to replicate because there is no possible brand test for [[BooleanData]].

Per spec, checking whether Boolean.prototype.valueOf.call(obj) throws will test whether an object has [[BooleanData]] internal slot.

# Darien Valentine (6 years ago)

Oh, thanks Claude, I missed that. I guess it is all replicable then.

# Claude Pache (6 years ago)

Le 5 août 2018 à 01:43, Michael Theriot <michael.lee.theriot at gmail.com> a écrit :

Try Number.prototype.valueOf.call(obj): it will throw a TypeError if and only if obj has no [[NumberData]] internal slot. Ditto for String, Boolean and Symbol.

I already mention this and demonstrate why it is not sufficient in my example.

Reiterated plainly:

JSON.stringify(Reflect.construct(Number, [], Number)); // "0"
JSON.stringify(Reflect.construct(Number, [], String)); // TypeError
JSON.stringify(Reflect.construct(Number, [], Object)); // null

Per spec, the three expressions should produce "0", as the three objects have a [[NumberData]] internal slot (step 4 of [1]). I guess there is some discrepancy between implementation and spec for those exotic edge cases?

[1]: tc39.github.io/ecma262/#sec-serializejsonproperty, tc39.github.io/ecma262/#sec-serializejsonproperty

# Darien Valentine (6 years ago)

Per spec, the three expressions should produce "0", as the three objects have a [[NumberData]] internal slot (step 4 of [1]). I guess there is some discrepancy between implementation and spec for those exotic edge cases?

There’s no discrepancy — they shouldn’t all produce "0", because the algorithm calls ToNumber on the value if it has the [[NumberData]] slot. This in turn calls ToPrimitive because the value is an object. This ultimately ends up calling the @@toPrimitive or toValue method of the object. In the case of the object with String.prototype for its prototype, this will be String.prototype.toValue, which throws if its receiver doesn’t have [[StringData]]. In the case of the object with Object.prototype for its prototype, this will be Object.prototype.toValue.

# Claude Pache (6 years ago)

Le 5 août 2018 à 04:50, Claude Pache <claude.pache at gmail.com> a écrit :

Le 5 août 2018 à 01:43, Michael Theriot <michael.lee.theriot at gmail.com <mailto:michael.lee.theriot at gmail.com>> a écrit :

Try Number.prototype.valueOf.call(obj): it will throw a TypeError if and only if obj has no [[NumberData]] internal slot. Ditto for String, Boolean and Symbol.

I already mention this and demonstrate why it is not sufficient in my example.

Reiterated plainly:

JSON.stringify(Reflect.construct(Number, [], Number)); // "0"
JSON.stringify(Reflect.construct(Number, [], String)); // TypeError
JSON.stringify(Reflect.construct(Number, [], Object)); // null

Per spec, the three expressions should produce "0", as the three objects have a [[NumberData]] internal slot (step 4 of [1]). I guess there is some discrepancy between implementation and spec for those exotic edge cases?

[1]: tc39.github.io/ecma262/#sec-serializejsonproperty, tc39.github.io/ecma262/#sec-serializejsonproperty

—Claude

I see that there is a difference in this algorithm between the current spec and ES 2015 for that particular step. From my tests, current stable Firefox and Safari (other browsers not tested) still follow the old algorithm.

# Claude Pache (6 years ago)

Le 5 août 2018 à 05:07, Claude Pache <claude.pache at gmail.com> a écrit :

Le 5 août 2018 à 04:50, Claude Pache <claude.pache at gmail.com <mailto:claude.pache at gmail.com>> a écrit :

Le 5 août 2018 à 01:43, Michael Theriot <michael.lee.theriot at gmail.com <mailto:michael.lee.theriot at gmail.com>> a écrit :

Try Number.prototype.valueOf.call(obj): it will throw a TypeError if and only if obj has no [[NumberData]] internal slot. Ditto for String, Boolean and Symbol.

I already mention this and demonstrate why it is not sufficient in my example.

Reiterated plainly:

JSON.stringify(Reflect.construct(Number, [], Number)); // "0"
JSON.stringify(Reflect.construct(Number, [], String)); // TypeError
JSON.stringify(Reflect.construct(Number, [], Object)); // null

Per spec, the three expressions should produce "0", as the three objects have a [[NumberData]] internal slot (step 4 of [1]). I guess there is some discrepancy between implementation and spec for those exotic edge cases?

[1]: tc39.github.io/ecma262/#sec-serializejsonproperty, tc39.github.io/ecma262/#sec-serializejsonproperty

—Claude

I see that there is a difference in this algorithm between the current spec and ES 2015 for that particular step. From my tests, current stable Firefox and Safari (other browsers not tested) still follow the old algorithm.

—Claude

Nevrmind, forget the previous message. The algorithm does indeed somewhat strange here.

# Isiah Meadows (6 years ago)

It's very subtle, but no, that's correct per spec, and any engine that doesn't do that is buggy. When JSON.stringify is invoked on a number, it first performs ToNumber on number objects (which each of these are) to get the number value, which itself indirectly calls valueOf. The first calls Number.prototype.valueOf with a number, which makes sense. The second creates an object with a [[NumberData]] internal slot, as expected, but it tries to call String.prototype.valueOf because the prototype of the object is set to new.target.prototype === String.prototype on creation. And by spec, that throws if the object doesn't have a [[StringData]] slot (which the Number constructor doesn't set, of course).

It's a similar situation with the third, but it's a little more indirect. First it calls Object.prototype.valueOf because new.target.prototype === Object.prototype when constructing the number. But this returns an object, not a primitive. in the ToNumber algorithm, if valueOf returns a non-primitive, it falls back to Object.prototype.toString(), and in either case, recursively coerces that. The result of coercing that result, "[object Number]", to a number is NaN, and JSON.stringify(NaN) returns null due to step 9.a/9.b in the SerializeJSONProperty algorithm (NaN is not a finite number).


Isiah Meadows contact at isiahmeadows.com, www.isiahmeadows.com

# Michael Theriot (6 years ago)

Regarding cases like Reflect.construct(Number, [], String), the reason this throws is because the ToNumber algorithm calls ToPrimitive if its operand is an object. This in turn will call String.prototype.valueOf on the object which does not have [[StringData]]. There’s nothing funny going on, it’s just a weird effect in aggregate. You would just need to implement all the steps here — internal ops like ToNumber, ToPrimitive, etc. It’s not that it "considers type", but rather that these algorithms will call methods by specific names on objects they receive. String.prototype and Object.prototype both implement "valueOf", but only the former will fail the slot check.

Thanks for demystifying this. I never realized it was just ducktyping .valueOf on the object.

Number.prototype.valueOf = () => 0;

JSON.stringify(1); // 1
JSON.stringify(new Number(1)); // 0

I guess what confused me is that it would detect a [[NumberData]] internal slot, but instead of unboxing to that value it tries to ducktype call .valueOf on the object. So the presence of [[NumberData]] changes the behavior even if it does not actually use this value.

# Darien Valentine (6 years ago)

I guess what confused me is that it would detect a [[NumberData]] internal slot, but instead of unboxing to that value it tries to ducktype call .valueOf on the object. So the presence of [[NumberData]] changes the behavior even if it does not actually use this value.

This surprised me too initially, but I think we can infer a rationale if we read between the lines a bit. A similar pattern appears in the JSON stringify algorithm itself (as opposed to the SerializeJSONProperty algorithm) when interpreting the "space" argument. So it might seem to be that it’s a peculiarity of how JSON-related operations are defined — but I don’t think that’s the pattern. What these two cases have in common is that either a numeric value or a string value would be valid: neither hint would be more correct. In the case of SerializeJSONProperty, this is because both string values and numeric values are valid JSON values; in the case of stringify’s space argument, it’s because this is overloaded to either specify a number of spaces or a whitespace string. In the absence of a reason to weight one to-primitive operation over another, it sniffs for the singular unambiguous clue about type that’s really available, slots.

Why doesn’t it then just use the slot value directly? Because this would depart from expectations about the @@toPrimitive / toValue / toString contract that is honored everywhere else, I believe. Instead it uses the slot as the effective basis of the primitive hint / a clue that said object should not be just "an object".

# Allen Wirfs-Brock (6 years ago)

On Aug 4, 2018, at 9:47 PM, Darien Valentine <valentinium at gmail.com <mailto:valentinium at gmail.com>> wrote:

I guess what confused me is that it would detect a [[NumberData]] internal slot, but instead of unboxing to that value it tries to ducktype call .valueOf on the object. So the presence of [[NumberData]] changes the behavior even if it does not actually use this value.

Fundamentally, the tests are trying to determine which values are supposed to be serialized as JSON primitive values and which need to be serialized as JSON objects (or arrays). The assumption in JSON stingify is that JS wrapper objects for numbers, strings, and booleans should be serialized as primitive values.

The ES JSON functions were based upon Crockford's original JSON 2 API and his implementation of it as it existed in mid 2008 (web.archive.org/web/20090213004934/http://www.json.org/json2.js, web.archive.org/web/20090213004934/http://www.json.org/json2.js). Crockford, in his design, made the decisions that the wrapper objects should serialize as primitives. In his implementation he accomplished that adding toJSON methods to the prototypes of Number, String, and Boolean. The body of those methods were: return this.valueOf();

In ES5, we wanted to avoid having to add a bunch of toJSON methods to the builtins (Date was the exception). So, we needed a cross-realm way to test for those primitive wrapper values. In ES1-5 the way to do that was to test the value of the [[Class]] internal property which was set for all built-in objects. If a Number or Stream wrapper [[Class]] value was detected, we did an internal ToNumber or ToString to get the primitive value. This is equivalent to what the built-in valueOf methods do.

In ES6, [[Class]] was eliminated as a specification device because it was confusing and starting to be misused. I realized that (for specification purposes) we could describe the equivalent test as a test for an internal slot that was unique to each kind of wrapper. So, the [[Class]] test was replaced with tests for [[NumberData]], [[StringDate]], and [[BooleanData]]. That specification change should not be observable to JS code. I didn’t change the ToNumber and ToString calls because that would have been a potentially observable change.

This surprised me too initially, but I think we can infer a rationale if we read between the lines a bit. A similar pattern appears in the JSON stringify algorithm itself (as opposed to the SerializeJSONProperty algorithm) when interpreting the "space" argument. So it might seem to be that it’s a peculiarity of how JSON-related operations are defined — but I don’t think that’s the pattern. What these two cases have in common is that either a numeric value or a string value would be valid: neither hint would be more correct. In the case of SerializeJSONProperty, this is because both string values and numeric values are valid JSON values; in the case of stringify’s space argument, it’s because this is overloaded to either specify a number of spaces or a whitespace string. In the absence of a reason to weight one to-primitive operation over another, it sniffs for the singular unambiguous clue about type that’s really available, the slot.

Yes, this is correct. It is because of overload resolution. Crockford’s original implementation only recognized primitive Numbers or Strings as the 3rd argument. Any object, including wrapper objects, passed as the 3rd argument were ignored. I thought that wrapper objects should also be accepted so I introduced the sequence you see in the spec. which is similar to what was also done in SerializeJSONProperty.

In retrospect is might have been better to have followed Crockford’s example. It’s very unlike that a wrapper object would ever be passed as that argument. It’s probably more likely that an object with a bespoke valueOf or toString might be passed and the algorithm, as specified, ignores such objects..