# How to decide when lenient parsing is the wrong choice

> Give a lenient parser to files people edit and a strict one to bodies programs send, keep the loaders apart, and let a test refuse a lenient import at the boundary.

Source: https://voxgig.com/howto/decide-when-lenient-parsing-is-wrong

- Audience: platform-team
- Level: intermediate
- Languages: typescript
- Verified: 2026-09-25
- Published: 2026-09-25

## 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](/howto/accept-comments-and-unquoted-keys-in-json).
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](https://www.rfc-editor.org/rfc/rfc8785.html) | 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](https://hjson.github.io/syntax.html) | 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](https://ajv.js.org/guide/getting-started.html) | 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](https://spec.json5.org/) | 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`](https://tabnas.dev/docs/packages/) | 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`](https://eslint.org/docs/latest/rules/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.

```bash
node edge.mjs
```

```text output
@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](https://json5.org/) 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.

```ts title="config.mjs"
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.

```ts title="webhook.mjs"
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](https://ajv.js.org/guide/getting-started.html) 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`.

```bash
npx jsonic -f config.jsonic < /dev/null
```

```text output
{"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](https://nodejs.org/api/test.html). Two of them are the
rule: every network fixture is refused at the boundary, and the boundary module imports nothing
lenient.

```ts title="boundary.test.mjs"
test('every network fixture is rejected at the boundary', () => {
  for (const [name, text] of NETWORK) {
    assert.throws(() => parseWebhook(text), /body is not JSON/, name)
  }
})
```

```ts title="boundary.test.mjs"
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)
  }
})
```

```bash
node --test boundary.test.mjs
```

```text output
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](https://www.rfc-editor.org/rfc/rfc9413.html) 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](/howto/accept-comments-and-unquoted-keys-in-json)
- [Write a validator whose schema looks like the data](/howto/write-a-schema-that-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.

[^1]: [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785.html), 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.

[^2]: The phrase is older than the slogan. [RFC 761](https://www.rfc-editor.org/rfc/rfc761.html)
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](https://www.rfc-editor.org/rfc/rfc1122.html), 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.