25

I've not seen (yet?) JSON.stringify to be non-deterministic in Node.JS.

There is no guarantee it to be deterministic on the specification level.

But what about V8; Is its implementation there deterministic? Is there a guarantee for it to remain deterministic for future V8 versions?

Edit:

With deterministic I mean that following assertion is true no matter what the value of json_str is. (Given the value is a valid JSON string.)

const obj = JSON.parse(json_str);
assert(JSON.stringify(obj)===JSON.stringify(obj)); // always true

Edit 2:

Actually, I'm also interested for the following assertion to be true

if( deepEqual(obj1, obj2) ) {
    assert(JSON.stringify(obj1)===JSON.stringify(obj2))
}

which is not the case (see answers).

brillout
  • 7,804
  • 11
  • 72
  • 84

5 Answers5

24

To clarify jmrk's answer;

According to the spec, integer keys are serialized in numeric order and non-integer keys in chronological order of property creation, e.g.;

var o = {};
o[2] = 2;
o.a = 3;
o.b = 4;
o["1"] = 1;

assert(JSON.stringify(o)==='{"1":1,"2":2,"a":3,"b":4}');

Therefore following assertion is guaranteed to be true

if( obj1 === obj2 ) {
  assert(JSON.stringify(obj1) === JSON.stringify(obj2));
}

but two "deep equal" objects may be serialized into different strings;

var obj1 = {};
obj1["a"] = true;
obj1["b"] = true;
assert(JSON.stringify(obj1)==='{"a":true,"b":true}');

var obj2 = {};
obj2["b"] = true;
obj2["a"] = true;
assert(JSON.stringify(obj2)==='{"b":true,"a":true}');

Spec quote;

  1. Let keys be a new empty List.
  2. For each own property key P of O that is an integer index, in ascending numeric index order, do

    a. Add P as the last element of keys.

  3. For each own property key P of O that is a String but is not an integer index, in ascending chronological order of property creation, do

    a. Add P as the last element of keys.

  4. For each own property key P of O that is a Symbol, in ascending chronological order of property creation, do

    a. Add P as the last element of keys.

  5. Return keys.

From https://tc39.github.io/ecma262/#sec-ordinaryownpropertykeys

brillout
  • 7,804
  • 11
  • 72
  • 84
  • 1
    For the record: the assertion `if (o1 === o2) { assert JSON.stringify(o1) === JSON.stringify(o2); }` is **not** generally true; it only holds for "simple" data objects (no non-trivial getters, no custom `.toJSON()`, no Proxies). Counter-example: `var o1 = {}; Object.defineProperty(o1, "time", {get: () => Date.now(), enumerable: true})` -- if you do `JSON.stringify(o1)` at different times, you obviously will get different result strings. – jmrk Jun 25 '21 at 10:45
10

If by "deterministic" you mean enumeration order of the object's properties: that is actually specified, and V8 follows the spec. See https://tc39.github.io/ecma262/#sec-ordinaryownpropertykeys. [Edit: this is the answer to your clarified definition, so yes, JSON.stringify is deterministic in that sense.]

If by "deterministic" you mean "always returns the same string for the same input object", then, well, no :-)

> var o = { toJSON: function() { return Math.random(); } }
> JSON.stringify(o);
< "0.37377773963616434"
> JSON.stringify(o);
< "0.8877065604993732"

Proxy objects and the replacer argument to JSON.stringify can also be used to create arbitrary behavior (even though JSON.stringify itself always does the same thing).

If by "deterministic" you mean something else, please specify.

jmrk
  • 34,271
  • 7
  • 59
  • 74
  • How does OrdinaryOwnPropertyKeys / your link relate to JSON.stringify? Actually, I don't see any enumeration order specified at https://tc39.github.io/ecma262/#sec-json.stringify. As for what I meant with deterministic, see OP edit. – brillout Feb 27 '17 at 21:23
  • 2
    Follow the chain: JSON.stringify -> SerializeJSONProperty -> SerializeJSONObject -> EnumerableOwnProperties -> [[OwnPropertyKeys]] -> OrdinaryOwnPropertyKeys. Your clarified definition amounts to exactly this. – jmrk Feb 28 '17 at 22:09
6

In case anyone would look for a function that'll make the JSON dump predictable, I wrote one:

const sortObj = (obj) => (
  obj === null || typeof obj !== 'object'
  ? obj
  : Array.isArray(obj)
  ? obj.map(sortObj)
  : Object.assign({}, 
      ...Object.entries(obj)
        .sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
        .map(([k, v]) => ({ [k]: sortObj(v) }),
    ))
);

Here's a composed deterministic JSON dump:

const deterministicStrigify = obj => JSON.stringify(deterministic(sortObj))

It works well with the examples above:

> obj1 = {};
> obj1.b = 5;
> obj1.a = 15;

> obj2 = {};
> obj2.a = 15;
> obj2.b = 5;

> deterministicStrigify(obj1)
'{"a":15,"b":5}'
> deterministicStrigify(obj2)
'{"a":15,"b":5}'
> JSON.stringify(obj1)
'{"b":5,"a":15}'
> JSON.stringify(obj2)
'{"a":15,"b":5}'
fodma1
  • 3,485
  • 1
  • 29
  • 49
4

Determinism in your terms boil down to these:

  1. Order => Will the Object data be marshaled in the same order?

Yes, the traversal through the object data happen in the same 'route' always.

  1. Content => Will the Object data be marshaled with same content?

Yes, unless the arbitrariness introduced through toJSON overrides as @jmrk explained above.

  1. Concurrency => Will the Object data be modified between the checks?

No, V8 script runner is single threaded, so no cluttered access happen.

  1. Specification => Are there clauses in the spec which violates determinism?

No, apart from the contextual replacers / overrides, the parser and stringify SHOULD produce same data everytime.

  1. Compatibility => Will all the stringify methods produce compatible data?

No, the spec is not clear on the order of listing Object fields so implementations are free to iterate through objects which means the data may be same to the 'purpose' and 'spirit', not comparable byte-to-byte.

Hope this helps!

Gireesh Punathil
  • 1,344
  • 8
  • 18
3

@fodma1's approach can be implemented a bit more simply using the second argument to JSON.stringify, the 'replacer' function:

const deterministicReplacer = (_, v) =>
  typeof v !== 'object' || v === null || Array.isArray(v) ? v :
    Object.fromEntries(Object.entries(v).sort(([ka], [kb]) => 
      ka < kb ? -1 : ka > kb ? 1 : 0));

Then:

JSON.stringify({b: 1, a: 0, c: {e: [], d: null, f: 1}}, deterministicReplacer, 2);
JSON.stringify({c: {f: 1, e: [], d: null}, b: 1, a: 0}, deterministicReplacer, 2);

Both give:

{"a":0,"b":1,"c":{"d":null,"e":[],"f":1}}

I also switch to naïve string comparisons for reasons of speed, assuming we care only about the repeatability of the sort order.

jawj
  • 463
  • 3
  • 11