How-to › Parse, validate and transform data

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.

Audience
Platform team
Level
intermediate
Topic
Parse config and data formats
Languages
TypeScript
Verified

Read first: Accept comments and unquoted keys in a JSON config file

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Canonical JSON, RFC 8785A payload that is hashed or signed and must serialize identically on every sideOne fixed serialization, no comments, no formatting choices, and a canonical encoder in every producerA person edits the text
HjsonFiles people write, where quotes and commas are noise and a reader exists for your languageUnquoted strings, so a stray token is a string and an HTML error page is a valueThe text arrives over a network
JSON.parse with a schema checkBodies written by programs, on either side of a network boundaryA rejection on the first stray byte, with an error that names a position and not a settingA person edits the text by hand
JSON5Hand-edited config with a published specification behind the dialectJSON’s commas stay required, so a missing one is still a startup failure, and hex and NaN are admittedThe file style drops commas between entries
jsonic and jsonic-cliFiles people write, and a build step that turns them into strict JSON for the runtimeA grammar so permissive that a missing comma, a trailing word, and an empty file all parseThe 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.

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

  1. 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

  2. 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.