One loader reads every JSON-shaped input in the service, and it is lenient because the config files needed comments. A proxy answered a webhook with an HTML error page, the loader turned it into an array of three strings, and the handler ran on it. The same loader made strict would fail every config file on its first comment.
What you get
You will end up with a written rule for which inputs get a lenient parser, and two loaders that follow it. A test fails when the boundary loader imports a lenient parser. This is for you if one codebase reads both hand-edited files and network payloads.
Short answer
Give leniency to input a person typed, and to nothing else. Config files a person edits go through jsonic, JSON5, or Hjson and then a schema, so an error names the setting. Request and webhook bodies go through JSON.parse and a schema of the same kind, so a truncated body or an error page is rejected rather than reinterpreted. Keep the two loaders in separate modules, and let a test refuse a lenient import at the boundary.
You will need
Node 22 or later, a config file that people edit, and an endpoint that receives JSON bodies.
Verified 2026-09-25 against Node 22.22.2, @tabnas/jsonic 0.7.1, @tabnas/jsonic-cli 0.5.8,
ajv 8.20.0, hjson 3.2.2, and json5 2.2.3. Loading the file itself is covered in
accept comments and unquoted keys in a JSON config file.
This page is about where that loader is allowed to run.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Canonical JSON, RFC 8785 | A payload that is hashed or signed and must serialize identically on every side | One fixed serialization, no comments, no formatting choices, and a canonical encoder in every producer | A person edits the text |
| Hjson | Files people write, where quotes and commas are noise and a reader exists for your language | Unquoted strings, so a stray token is a string and an HTML error page is a value | The text arrives over a network |
| JSON.parse with a schema check | Bodies written by programs, on either side of a network boundary | A rejection on the first stray byte, with an error that names a position and not a setting | A person edits the text by hand |
| JSON5 | Hand-edited config with a published specification behind the dialect | JSON’s commas stay required, so a missing one is still a startup failure, and hex and NaN are admitted | The file style drops commas between entries |
jsonic and jsonic-cli | Files people write, and a build step that turns them into strict JSON for the runtime | A grammar so permissive that a missing comma, a trailing word, and an empty file all parse | The text arrives over a network |
Voxgig maintains jsonic. It is one of five options here, not the recommendation.
The rows split by who wrote the bytes. JSON5 and jsonic loosen the text for a person and pay for
it in what they accept from a program. JSON5 admits hexadecimal and NaN, and jsonic admits a
missing comma. Canonical JSON and JSON.parse with a schema refuse the malformed bodies below, and
pay for it with an error a person cannot act on. No row is the answer for both
sides, which is the whole finding.
Write the rule down
The rule fits in three sentences, and it belongs in the contributing guide next to the linter configuration.
Input a person typed gets a lenient parser and then a schema. Input a program wrote gets a strict parser and then a schema. Anything that arrived over a network was written by a program, whatever a person typed into it upstream, because a program serialized it before it left.
The enforcement is a review rule with a test behind it: the module that parses bodies imports no
lenient parser. In a larger codebase the same rule is the ESLint rule
no-restricted-imports, with the
lenient packages listed under paths and a message that points at this rule.
See both failure modes on the same inputs
One hand-edited file and six bodies a webhook endpoint received, through four parsers.
node edge.mjs
@tabnas/jsonic 0.7.1 | json5 2.2.3 | hjson 3.2.2 | ajv 8.20.0
config.jsonic through JSON.parse
Unexpected token '#', "# Service "... is not valid JSON
config.jsonic through the config loader
{"port":8080,"pool":{"min":2,"max":20},"admin_origins":["https://ops.example.com","https://admin.example.com"]}
the same file with max: 500 through the config loader
config: /pool/max must be <= 100
body JSON.parse JSON5 Hjson jsonic
proxy error page rejects rejects accepts accepts
truncated body rejects rejects rejects rejects
missing comma rejects rejects rejects accepts
trailing junk rejects rejects rejects accepts
leading zero rejects rejects rejects accepts
empty body rejects rejects accepts accepts
what jsonic built from the ones it accepted
proxy error page ["<html><body><h1>502","Bad","Gateway</h1></body></html>"]
missing comma {"event":"payment.succeeded","amount":1000,"currency":"EUR"}
trailing junk [{"event":"payment.succeeded","amount":1000,"currency":"EUR"},"DEBUG"]
leading zero {"event":"payment.succeeded","amount":1000,"currency":"EUR"}
empty body undefined
The first half is the human edge. JSON.parse names a character and a token, and says nothing
about comments, which is the thing the person did. The config loader reads the same file, then
reports /pool/max must be <= 100 when a value is out of range, which is a message a person can
act on because it names the setting.
The second half is the boundary. jsonic accepted five of the six, and what it built is the
problem. An error page became an array of strings, a missing comma became a valid event, and a
debugging word appended by a broken sender became a second element. An empty body became
undefined. None of those bodies was sent on purpose, and a strict parser says so.1 Hjson
took the error page as one string and the empty body as {}. JSON5 rejected all six here, and
its own site lists what it admits elsewhere: hexadecimal numbers, an
explicit plus sign, and NaN, none of which a program emits by design.
Keep the two loaders in separate modules
The human edge, where the leniency lives and the schema runs on the value.
export function parseConfig(text, name = 'config') {
const value = Jsonic(text)
if (!validate(value)) {
const lines = validate.errors.map((e) => `${e.instancePath || '/'} ${e.message}`)
throw new Error(`${name}: ${lines.join('; ')}`)
}
return value
}
The boundary, where nothing lenient is imported.
export function parseWebhook(text) {
let value
try {
value = JSON.parse(text)
} catch (err) {
throw new Error(`body is not JSON: ${err.message}`)
}
if (!validate(value)) {
const lines = validate.errors.map((e) => `${e.instancePath || '/'} ${e.message}`)
throw new Error(`body rejected: ${lines.join('; ')}`)
}
return value
}
Both run the same validator, Ajv with
allErrors on, so every complaint about a file arrives at once. The difference is one line, the
parser, and that line is the decision this page is about. A shared load(text) helper that both
modules call would erase it.
Convert at the edge instead of parsing there
The other way to keep the runtime strict is to run the lenient parser once, at build time, and
ship strict JSON. jsonic-cli reads a file and prints the value with JSON.stringify.
npx jsonic -f config.jsonic < /dev/null
{"port":8080,"pool":{"min":2,"max":20},"admin_origins":["https://ops.example.com","https://admin.example.com"]}
Commit the source file, generate the JSON in the build, and load the JSON with JSON.parse and
the schema. The lenient parser then never runs in production at all, which is the cleanest
version of the rule. It also reads standard input whenever that is not a terminal, -f or not.
On a pipe it waits for the other end to close, then merges what arrived over the file, so an
error page upstream becomes the config. Close standard input, as the preceding command does.
Check it worked
Seven tests, with the Node test runner. Two of them are the rule: every network fixture is refused at the boundary, and the boundary module imports nothing lenient.
test('every network fixture is rejected at the boundary', () => {
for (const [name, text] of NETWORK) {
assert.throws(() => parseWebhook(text), /body is not JSON/, name)
}
})
test('the boundary module imports no lenient parser', () => {
const source = readFileSync(new URL('./webhook.mjs', import.meta.url), 'utf8')
const specifiers = [...source.matchAll(/(?:from|import)\s*\(?\s*['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)/g)]
.map((m) => m[1] ?? m[2])
assert.ok(specifiers.length > 0, 'the module imports something, so there is something to check')
for (const name of specifiers) {
assert.doesNotMatch(name, /jsonic|json5|hjson/i, name)
}
})
node --test boundary.test.mjs
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 147.267656
A third test pins the reason the rule exists: it feeds the same six fixtures to jsonic, the parser behind the config loader, and asserts that five are accepted. When a jsonic release tightens one of them, that test fails, and the page’s claim gets re-read rather than silently outliving the behavior.
When it goes wrong
A handler runs on a body that is not a body. An empty request parsed to undefined, or an error
page parsed to an array, and the code after the parser checked body.amount and found nothing
there. The shared loader is the cause. Split it, and make the boundary parser throw on anything
JSON.parse refuses.
The config error is a character offset. A strict parser reached a comment and reported the character, and the person who wrote the comment has no idea what they did wrong. Move the file to the lenient loader, and move the strictness into the schema, where an error can name the setting.
The build passes, but the boundary still reads leniently. A wrapper module re-exports the config loader under another name, and the import test looked only at direct imports. Search for the lenient package names across the boundary directory, or set the ESLint rule at the directory level, where a re-export is still an import.
When not to do this
Do not put jsonic, JSON5, or Hjson on a boundary. Every parser in the table accepts something a program never meant to send, and jsonic accepts the most, by design, because it was built for text a person types. On a webhook that is a sender’s bug reinterpreted as a valid event.
Do not make the config strict to match the boundary. The other failure mode is real too: a person
who adds a comment gets Unexpected token '#' and a character offset, and the fix they reach for
is deleting the explanation.
Do not read Postel’s law as a licence for the lenient side. The principle came from TCP,2 and RFC 9413 from the IAB says that consequences for interoperability accumulate over time when implementations silently accept faulty input. A boundary that accepts a missing comma today is negotiating tomorrow’s format with a broken sender.
Do not build one loader with a lenient flag. The flag is a decision made at every call site,
and the call site that forgets it is the one in the webhook handler.
Related how-tos
- Accept comments and unquoted keys in a JSON config file
- Write a validator whose schema looks like the data
Last verified
Verified 2026-09-25 against Node 22.22.2, @tabnas/jsonic 0.7.1, @tabnas/jsonic-cli 0.5.8,
ajv 8.20.0, hjson 3.2.2, and json5 2.2.3. Every output block is what the command preceding it
printed.
Footnotes
-
RFC 8785, the canonical form in the table, exists because signing needs the same bytes on both sides, and it is published as an Independent Submission. Its status section says the RFC Editor chose to publish it at its discretion and makes no statement about its value for implementation or deployment. A scheme for making JSON byte-identical everywhere, issued with a statement that the issuer is not vouching for it. ↩︎ Back to text
-
The phrase is older than the slogan. RFC 761 is the January 1980 TCP specification. Its section 2.10 says “be conservative in what you do, be liberal in what you accept from others,” and calls that a general principle of robustness. RFC 1122, from 1989, turned it round as “Be liberal in what you accept, and conservative in what you send,” beside advice to assume the network is full of malevolent entities. The half everyone quotes and the half about malevolent entities were published together, and only one of them made it onto the posters. ↩︎ Back to text