bastianplsfix

JSON

JSON is a text format that carries six kinds of value: objects, arrays, strings, numbers, booleans, and null. Two functions move between it and JavaScript, JSON.stringify and JSON.parse, and two things matter more than the API, which this entry is mostly about. What JSON cannot carry: undefined, functions, symbols, NaN, Infinity, Map, Set, bigint, and anything with a cycle, most of which disappear without a word. And what JSON.parse returns: any, so the moment you parse something the type system stops helping, which is the single most common way any gets into an otherwise typed program.

The syntax is worth knowing precisely because it is frozen: double-quoted keys, no trailing commas, no comments, and no undefined. It will never gain any of them. The specification says so, so stop waiting.

Create programs/json.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:

import {
assert,
assertEquals,
assertStrictEquals,
assertThrows,
} from "@std/assert";

Follow the page as you add and revise the runnable examples below that import.

two functions, one line by default

Deno.test("two functions, one line by default", () => {
assertStrictEquals(
JSON.stringify({ name: "Ada", tags: ["a", "b"] }),
'{"name":"Ada","tags":["a","b"]}',
);

assertEquals(JSON.parse('{"name":"Ada"}'), { name: "Ada" });

assertStrictEquals(
JSON.stringify({ a: [1] }, null, 2),
`{\n "a": [\n 1\n ]\n}`,
);
});
Check programs/json.test.ts
running 1 test from ./programs/json.test.ts
two functions, one line by default ... ok (346µs)

ok | 1 passed | 0 failed (1ms)

One line by default. Pass a number as the third argument and you get indented lines, which is what you want for anything a person will read or a diff will show; a string works there too, so "\t" indents with tabs. The second argument is a replacer, which a later step gets to.

what silently disappears

An object holding three values JSON cannot represent, and one it can. Predict the output:

Deno.test("what silently disappears", () => {
assertStrictEquals(
JSON.stringify({ a: undefined, b: () => {}, c: Symbol(), d: 1 }),
'{"a":null,"b":null,"c":null,"d":1}',
);
});
Check programs/json.test.ts
running 2 tests from ./programs/json.test.ts
...
what silently disappears ... FAILED (9ms)

ERRORS

what silently disappears => ./programs/json.test.ts:23:6
error: AssertionError: Values are not strictly equal.

[Diff] Actual / Expected

- {"d":1}
+ {"a":null,"b":null,"c":null,"d":1}

FAILURES

what silently disappears => ./programs/json.test.ts:23:6

FAILED | 1 passed | 1 failed (11ms)

error: Test failed

Three properties are simply gone. A value JSON cannot represent is omitted, and in an object that means the property vanishes; the null treatment does exist, and it belongs to arrays, where a position cannot be dropped. Correct the prediction and pin the full list of silent losses:

Deno.test("what silently disappears", () => {
assertStrictEquals(
JSON.stringify({ a: undefined, b: () => {}, c: Symbol(), d: 1 }),
'{"d":1}',
);

assertStrictEquals(
JSON.stringify([undefined, () => {}, Symbol(), 1]),
"[null,null,null,1]",
);

assertStrictEquals(JSON.stringify(NaN), "null");
assertStrictEquals(JSON.stringify(Infinity), "null");
assertStrictEquals(JSON.stringify(-0), "0");

assertStrictEquals(JSON.stringify(new Map([["a", 1]])), "{}");
assertStrictEquals(JSON.stringify(new Set([1, 2])), "{}");
});
what silently disappears ... ok (52µs)

Seven kinds of loss, no errors, no warnings. The first two blocks are the same rule with different consequences, so the same value survives differently depending on where it sits. NaN and Infinity become null, because JSON's number syntax has no way to write them, and -0 loses its sign. And a Map or a Set becomes {}, which is the worst of these, because it looks like an empty object rather than a mistake, the point the maps page makes from the other side. None of this is a bug: JSON has six types and JavaScript has more, so something has to give. The problem is only that giving up is silent.

what throws instead

Deno.test("what throws instead", () => {
assertThrows(
() => JSON.stringify(123n),
TypeError,
"Do not know how to serialize a BigInt",
);

const cycle: Record<string, unknown> = {};
cycle.self = cycle;
assertThrows(
() => JSON.stringify(cycle),
TypeError,
"Converting circular structure to JSON",
);
});
what throws instead ... ok (3ms)

Two cases get an exception, and the contrast with the silent list is the interesting part. A bigint throws because there is a right answer JSON cannot express and guessing would lose information: writing it as a number loses precision, and writing it as a string changes its type. A cycle throws because there is no answer at all, only an infinite string. So the rule seems to be that a value with an obviously wrong fallback is dropped quietly, and a value with no acceptable fallback throws. Not a principle you can lean on, but it explains why the two lists look arbitrary.

toJSON is a hook on the value

Deno.test("toJSON is a hook on the value", () => {
class Point {
constructor(readonly x: number, readonly y: number) {}

toJSON(): { x: number; y: number } {
return { x: this.x, y: this.y };
}
}

assertStrictEquals(JSON.stringify(new Point(3, 5)), '{"x":3,"y":5}');

assertStrictEquals(
JSON.stringify(new Date(Date.UTC(2077, 0, 27))),
'"2077-01-27T00:00:00.000Z"',
);
assertStrictEquals(
JSON.stringify(Temporal.PlainDate.from("2077-01-27")),
'"2077-01-27"',
);
});
toJSON is a hook on the value ... ok (218µs)

If a value has a toJSON method, stringify calls it and serialises whatever comes back. That is how a Date becomes an ISO string without stringify knowing anything about dates, and how every Temporal type does the same, measured on the dates and times page. It is the cleanest extension point in this API, because the knowledge lives on the class rather than in the caller: a class with a toJSON serialises correctly everywhere, including inside something else, and needs no replacer and no special case at any call site. Note that it is one-directional. There is no fromJSON, so parsing back is your problem, and a static factory method on the class is the usual answer.

the visitors walk in opposite directions

Both functions take an optional visitor that sees every value: a replacer for stringify, a reviver for parse. Predict the order the reviver visits in:

Deno.test("the visitors walk in opposite directions", () => {
const stringifyOrder: string[] = [];
JSON.stringify({ a: 1, b: { c: 2 } }, (key, value) => {
stringifyOrder.push(key);
return value;
});
assertEquals(stringifyOrder, ["", "a", "b", "c"]);

const parseOrder: string[] = [];
JSON.parse('{"a":1,"b":{"c":2}}', (key, value) => {
parseOrder.push(key);
return value;
});
assertEquals(parseOrder, ["", "a", "b", "c"]);
});
Check programs/json.test.ts
running 5 tests from ./programs/json.test.ts
...
the visitors walk in opposite directions ... FAILED (8ms)

ERRORS

the visitors walk in opposite directions => ./programs/json.test.ts:82:6
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

[
+ "",
"a",
- "c",
"b",
- "",
+ "c",
]

FAILURES

the visitors walk in opposite directions => ./programs/json.test.ts:82:6

FAILED | 4 passed | 1 failed (13ms)

error: Test failed

The reviver saw the root last, not first. Each visitor gets a key and a value, what it returns replaces the value, returning undefined omits it, and the root is visited with the key "". The directions differ, and the reason is worth having: stringify walks top-down because it is taking things apart, and one JavaScript object may expand into a whole tree of JSON values, where parse walks bottom-up because it is putting things together, so the parts have to be converted before the whole can be. Which is why a reviver can look at a fully-revived child, and a replacer cannot. Correct the prediction:

Deno.test("the visitors walk in opposite directions", () => {
const stringifyOrder: string[] = [];
JSON.stringify({ a: 1, b: { c: 2 } }, (key, value) => {
stringifyOrder.push(key);
return value;
});
assertEquals(stringifyOrder, ["", "a", "b", "c"]);

const parseOrder: string[] = [];
JSON.parse('{"a":1,"b":{"c":2}}', (key, value) => {
parseOrder.push(key);
return value;
});
assertEquals(parseOrder, ["a", "c", "b", ""]);
});
the visitors walk in opposite directions ... ok (85µs)

a replacer and a reviver carry an unsupported type

Deno.test("a replacer and a reviver carry an unsupported type", () => {
const REGEX_MARKER = "__regexp__";
type EncodedRegExp = { source: string; flags: string };

function replacer(_key: string, value: unknown): unknown {
if (value instanceof RegExp) {
return { [REGEX_MARKER]: true, source: value.source, flags: value.flags };
}
return value;
}

function reviver(_key: string, value: unknown): unknown {
if (typeof value === "object" && value !== null && REGEX_MARKER in value) {
const encoded = value as unknown as EncodedRegExp;
return new RegExp(encoded.source, encoded.flags);
}
return value;
}

const original = { name: "oak", pattern: /a+b/iv };
const text = JSON.stringify(original, replacer);

assertStrictEquals(
text,
'{"name":"oak","pattern":{"__regexp__":true,"source":"a+b","flags":"iv"}}',
);

const restored = JSON.parse(text, reviver) as typeof original;
assert(restored.pattern instanceof RegExp);
assertStrictEquals(restored.pattern.source, "a+b");
assertStrictEquals(restored.pattern.flags, "iv");
});
a replacer and a reviver carry an unsupported type ... ok (101µs)

A pair of visitors is how you get something JSON does not support through it and back again. A regular expression is the clearest example, since it stringifies to {} by default and is fully described by two strings. The marker property is the whole design. It has to be something no real data would contain, because the reviver has no other way to tell an encoded value from an object that happens to have source and flags; that is not a rigorous check and cannot be one, which is the honest limit of the technique. Reach for toJSON first when the type is yours. A replacer is for types that are not.

numbers lose precision, and a round trip can lose more

Twenty digits of valid JSON. Predict what comes back out:

Deno.test("numbers lose precision, and a round trip can lose more", () => {
assertStrictEquals(
String(JSON.parse("12345678901234567890")),
"12345678901234567890",
);
});
Check programs/json.test.ts
running 7 tests from ./programs/json.test.ts
...
numbers lose precision, and a round trip can lose more ... FAILED (8ms)

ERRORS

numbers lose precision, and a round trip can lose more => ./programs/json.test.ts:133:6
error: AssertionError: Values are not strictly equal.

[Diff] Actual / Expected

- 12345678901234567000
+ 12345678901234567890

FAILURES

numbers lose precision, and a round trip can lose more => ./programs/json.test.ts:133:6

FAILED | 6 passed | 1 failed (14ms)

error: Test failed

Twenty digits in, seventeen digits and three zeroes out, and nothing said so. JSON's numbers have no size limit and JavaScript's have a double's precision, from the numbers page, so parsing a large integer loses its last digits. Correct the prediction and add the two-step loss:

Deno.test("numbers lose precision, and a round trip can lose more", () => {
assertStrictEquals(
String(JSON.parse("12345678901234567890")),
"12345678901234567000",
);

const huge = JSON.parse("1e999");
assertStrictEquals(huge, Infinity);
assertStrictEquals(JSON.stringify(huge), "null");
});
numbers lose precision, and a round trip can lose more ... ok (26µs)

The second case has a surprising middle. 1e999 is valid JSON, too large for a double, and parses to Infinity, and Infinity stringifies to null, so 1e999 round-trips to null through two silent conversions, a long way from where it started. This matters for anything carrying an identifier from a database that uses 64-bit integers, and the usual mitigation is to send those as strings. There is now a better one, in the next step.

rawJSON, and the reviver's source

The precision fix is two ES2025 features, and the types have not caught up with the runtime. Write them plainly in a scratch file programs/raw-json-types.ts and check it:

const raw = JSON.rawJSON("12345678901234567890");
console.log(JSON.isRawJSON(raw));

JSON.parse("1", (_key: string, value: unknown, context: { source: string }) => {
console.log(context.source);
return value;
});
Check programs/raw-json-types.ts
TS2339 [ERROR]: Property 'rawJSON' does not exist on type 'JSON'.
const raw = JSON.rawJSON("12345678901234567890");
~~~~~~~
at file:///programs/raw-json-types.ts:1:18

TS2339 [ERROR]: Property 'isRawJSON' does not exist on type 'JSON'.
console.log(JSON.isRawJSON(raw));
~~~~~~~~~
at file:///programs/raw-json-types.ts:2:18

TS2345 [ERROR]: Argument of type '(_key: string, value: unknown, context: { source: string; }) => unknown' is not assignable to parameter of type '(this: any, key: string, value: any) => any'.
Target signature provides too few arguments. Expected 3 or more, but got 2.
JSON.parse("1", (_key: string, value: unknown, context: { source: string }) => {
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
at file:///programs/raw-json-types.ts:4:17

Found 3 errors.

error: Type checking failed.

The runtime has all three and the lib has none of them, which is the opposite of the usual complaint about these types. Delete the scratch file, and cast both halves into reach:

Deno.test("rawJSON, and the reviver's source", () => {
const rawJSON = JSON as unknown as {
rawJSON(text: string): unknown;
isRawJSON(value: unknown): boolean;
};

const raw = rawJSON.rawJSON("12345678901234567890");

assert(rawJSON.isRawJSON(raw));
assertStrictEquals(
JSON.stringify({ big: raw }),
'{"big":12345678901234567890}',
);

const parseWithSource = JSON.parse as (
text: string,
reviver: (
key: string,
value: unknown,
context: { source: string },
) => unknown,
) => unknown;

const sources: string[] = [];
parseWithSource("12345678901234567890", (_key, value, context) => {
sources.push(context.source);
return value;
});
assertEquals(sources, ["12345678901234567890"]);
});
rawJSON, and the reviver's source ... ok (45µs)

JSON.rawJSON(text) wraps a piece of literal JSON text that stringify emits verbatim, so a number never becomes a double on the way out, every digit intact; it validates its argument, so you cannot inject arbitrary text through it. And the matching half for the way in is the reviver's third parameter, a context object whose source property holds the original text of the value, captured here before it became a damaged double, so you can convert to a bigint or a decimal library from the digits. Both work in Deno today, and using either needs a cast, which is a temporary state of affairs worth knowing before you conclude the feature is missing.

two things JSON does not promise

Deno.test("two things JSON does not promise", () => {
assertEquals(JSON.parse('{"a":1,"a":2}'), { a: 2 });

assertStrictEquals(
JSON.stringify(JSON.parse('{"b":1,"a":2,"10":3,"2":4}')),
'{"2":4,"10":3,"b":1,"a":2}',
);
});
two things JSON does not promise ... ok (24µs)

A duplicate key parses, and the last occurrence wins: no error, no warning, and the first value is gone, which matters because two producers concatenating output can create one, and because it is a way to smuggle a value past a validator that reads the text differently from the parser. And key order is not preserved, for a reason that is not JSON's: the parsed object follows JavaScript's own key rule, integer-like keys first in numeric order and everything else in insertion order, exactly as the objects as dictionaries page describes, so "b" before "a" survives and "10" before "2" does not. If order matters, do not use an object: use an array of pairs, and rebuild whatever you need from it.

JSON.parse returns any

Put a small lie factory in a scratch file programs/parse-returns-any.ts:

const config = JSON.parse('{"retries":3}');

const retries: string = config.retries;
const nested: boolean = config.anything.deeply.nested;

console.log(retries.toUpperCase(), nested);

deno check programs/parse-returns-any.ts passes:

Check programs/parse-returns-any.ts

And deno run programs/parse-returns-any.ts dies:

error: Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'deeply')
const nested: boolean = config.anything.deeply.nested;
^
at file:///programs/parse-returns-any.ts:4:41

That pair is the finding. retries is annotated string and holds a number, config.anything.deeply.nested is three imaginary properties annotated boolean, the checker reports nothing, and the program falls over at the second line. JSON.parse is declared as returning any, which is the correct declaration, since it genuinely cannot know, but any is contagious in the way the any, unknown, never page describes: it spreads to everything you take out of it and silences every check downstream. This one call is the main door through which any enters a program that is otherwise fully typed, and the most valuable thing in this entry is the habit in the next step. Delete the scratch file.

parse into unknown, then validate

Deno.test("parse into unknown, then validate", () => {
type Config = { retries: number; verbose: boolean };

function parseConfig(text: string): Config {
const raw: unknown = JSON.parse(text);

if (typeof raw !== "object" || raw === null) {
throw new TypeError("config must be an object");
}
const { retries, verbose } = raw as Record<string, unknown>;
if (typeof retries !== "number" || typeof verbose !== "boolean") {
throw new TypeError("config is missing retries or verbose");
}
return { retries, verbose };
}

assertEquals(parseConfig('{"retries":3,"verbose":true}'), {
retries: 3,
verbose: true,
});

assertThrows(
() => parseConfig('"just a string"'),
TypeError,
"config must be an object",
);
assertThrows(
() => parseConfig('{"retries":"three","verbose":true}'),
TypeError,
"config is missing retries or verbose",
);
});
parse into unknown, then validate ... ok (74µs)

Annotating the result unknown costs one word and turns the any off, and after that the checker will not let you touch the value until you have proved what it is, with the narrowing tools from the unions and narrowing page. The function's return type is then a promise it has actually kept. By hand for a small shape, with a validation library for a large one, and either way the shape of the code is the same: unknown at the boundary, a declared type after it, and a thrown error in between. And do not lie with an annotation, because JSON.parse(text) as Config compiles and checks nothing, which is worse than any since it looks like it did something.

do not copy with a JSON round trip

Deno.test("do not copy with a JSON round trip", () => {
const original = {
when: new Date(Date.UTC(2077, 0, 27)),
lookup: new Map([["a", 1]]),
missing: undefined,
};

const cloned = structuredClone(original);
assert(cloned.when instanceof Date);
assert(cloned.lookup instanceof Map);
assertStrictEquals(cloned.lookup.get("a"), 1);
assert("missing" in cloned);

const roundTripped = JSON.parse(JSON.stringify(original));
assertStrictEquals(typeof roundTripped.when, "string");
assertEquals(roundTripped.lookup, {});
assert(!("missing" in roundTripped));
});
do not copy with a JSON round trip ... ok (140µs)

JSON.parse(JSON.stringify(x)) is a well-known deep-copy trick, and it loses everything on the silent list: the Date came back a string, the Map came back an empty object, and the undefined property is missing. structuredClone keeps all three, is built in, and handles cycles, from the values and references page. The comparison is the whole recommendation.

The whole entry

Run the whole reference suite:

Check programs/any-unknown-never.test.ts
Check programs/arrays.test.ts
Check programs/assignment.test.ts
Check programs/async-functions.test.ts
Check programs/async-iteration.test.ts
Check programs/branching.test.ts
Check programs/classes.test.ts
Check programs/closures.test.ts
Check programs/conversion-and-coercion.test.ts
Check programs/designing-error-types.test.ts
Check programs/destructuring.test.ts
Check programs/equality.test.ts
Check programs/errors-and-exceptions.test.ts
Check programs/functions.test.ts
Check programs/generators.test.ts
Check programs/iterables-and-iterators.test.ts
Check programs/iterator-helpers.test.ts
Check programs/json.test.ts
Check programs/loops.test.ts
Check programs/maps.test.ts
Check programs/matching-and-replacing.test.ts
Check programs/module-specifiers.test.ts
Check programs/modules.test.ts
Check programs/mutating-arrays.test.ts
Check programs/nothing-twice.test.ts
Check programs/numbers.test.ts
Check programs/objects-as-dictionaries.test.ts
Check programs/objects.test.ts
Check programs/ordering-and-sorting.test.ts
Check programs/parameters-and-arguments.test.ts
Check programs/private-class-members.test.ts
Check programs/promise-combinators.test.ts
Check programs/promises.test.ts
Check programs/prototypes-and-inheritance.test.ts
Check programs/read-only.test.ts
Check programs/regular-expressions.test.ts
Check programs/scope-and-declarations.test.ts
Check programs/sentinels.test.ts
Check programs/sets.test.ts
Check programs/strings.test.ts
Check programs/subclassing.test.ts
Check programs/symbols.test.ts
Check programs/tagged-templates.test.ts
Check programs/text-and-characters.test.ts
Check programs/the-event-loop.test.ts
Check programs/the-value-of-this.test.ts
Check programs/transforming-arrays.test.ts
Check programs/truthiness.test.ts
Check programs/unicode-in-patterns.test.ts
Check programs/unions-and-narrowing.test.ts
Check programs/values-and-references.test.ts
Check programs/weak-collections.test.ts
Check programs/what-a-type-is.test.ts
running 10 tests from ./programs/any-unknown-never.test.ts
...
running 13 tests from ./programs/arrays.test.ts
...
running 9 tests from ./programs/assignment.test.ts
...
running 10 tests from ./programs/async-functions.test.ts
...
running 11 tests from ./programs/async-iteration.test.ts
...
running 10 tests from ./programs/branching.test.ts
...
running 11 tests from ./programs/classes.test.ts
...
running 6 tests from ./programs/closures.test.ts
...
running 11 tests from ./programs/conversion-and-coercion.test.ts
...
running 10 tests from ./programs/designing-error-types.test.ts
...
running 14 tests from ./programs/destructuring.test.ts
...
running 11 tests from ./programs/equality.test.ts
...
running 10 tests from ./programs/errors-and-exceptions.test.ts
...
running 11 tests from ./programs/functions.test.ts
...
running 12 tests from ./programs/generators.test.ts
...
running 14 tests from ./programs/iterables-and-iterators.test.ts
...
running 12 tests from ./programs/iterator-helpers.test.ts
...
running 11 tests from ./programs/json.test.ts
two functions, one line by default ... ok (315µs)
what silently disappears ... ok (47µs)
what throws instead ... ok (3ms)
toJSON is a hook on the value ... ok (191µs)
the visitors walk in opposite directions ... ok (72µs)
a replacer and a reviver carry an unsupported type ... ok (62µs)
numbers lose precision, and a round trip can lose more ... ok (31µs)
rawJSON, and the reviver's source ... ok (48µs)
two things JSON does not promise ... ok (34µs)
parse into unknown, then validate ... ok (84µs)
do not copy with a JSON round trip ... ok (127µs)
running 14 tests from ./programs/loops.test.ts
...
running 15 tests from ./programs/maps.test.ts
...
running 15 tests from ./programs/matching-and-replacing.test.ts
...
running 6 tests from ./programs/module-specifiers.test.ts
...
running 12 tests from ./programs/modules.test.ts
...
running 10 tests from ./programs/mutating-arrays.test.ts
...
running 11 tests from ./programs/nothing-twice.test.ts
...
running 15 tests from ./programs/numbers.test.ts
...
running 14 tests from ./programs/objects-as-dictionaries.test.ts
...
running 13 tests from ./programs/objects.test.ts
...
running 12 tests from ./programs/ordering-and-sorting.test.ts
...
running 11 tests from ./programs/parameters-and-arguments.test.ts
...
running 11 tests from ./programs/private-class-members.test.ts
...
running 11 tests from ./programs/promise-combinators.test.ts
...
running 11 tests from ./programs/promises.test.ts
...
running 12 tests from ./programs/prototypes-and-inheritance.test.ts
...
running 12 tests from ./programs/read-only.test.ts
...
running 13 tests from ./programs/regular-expressions.test.ts
...
running 9 tests from ./programs/scope-and-declarations.test.ts
...
running 8 tests from ./programs/sentinels.test.ts
...
running 13 tests from ./programs/sets.test.ts
...
running 10 tests from ./programs/strings.test.ts
...
running 11 tests from ./programs/subclassing.test.ts
...
running 10 tests from ./programs/symbols.test.ts
...
running 8 tests from ./programs/tagged-templates.test.ts
...
running 10 tests from ./programs/text-and-characters.test.ts
...
running 9 tests from ./programs/the-event-loop.test.ts
...
running 10 tests from ./programs/the-value-of-this.test.ts
...
running 13 tests from ./programs/transforming-arrays.test.ts
...
running 9 tests from ./programs/truthiness.test.ts
...
running 11 tests from ./programs/unicode-in-patterns.test.ts
...
running 13 tests from ./programs/unions-and-narrowing.test.ts
...
running 13 tests from ./programs/values-and-references.test.ts
...
running 9 tests from ./programs/weak-collections.test.ts
...
running 7 tests from ./programs/what-a-type-is.test.ts
...

ok | 587 passed | 0 failed (1s)

Eleven tests, and the practice is short. Parse into unknown and validate, because that one word turns the any off at the main door it comes through. Do not lie with an annotation. Give a class a toJSON rather than a separate serialiser, and a static factory for the way back. Never put a Map in something you serialise, and remember that undefined properties vanish, so a field meaning explicitly-not-set needs null, the distinction the nothing, twice page draws. Do not copy with a JSON round trip, because structuredClone exists. Reach for rawJSON and the reviver's source when precision matters, and expect to cast until the types catch up. And stop waiting for comments, because JSON's grammar is frozen by its own standard, and the answer for an annotated configuration file is a format that compiles to JSON, or a different format entirely.