Skip to content
jsonforge.app
Back to blog
Best practices8 min read

JSON Config Files Done Right: Environments, Validation, and the Comments Problem

Configuration is the part of your system that changes most often and is tested least. JSON is a solid format for it — right up until someone wants a comment, a second environment, or a secret in it. This is a practical playbook for JSON config: layering environments, the comments problem and its honest workarounds, validation in CI with JSON Schema, keeping secrets out, and the habits that keep package.json and tsconfig.json manageable at scale.

Layer config: base plus environment override

Never fork the whole config per environment. Three copies of mostly-identical JSON are three places to forget a key and three files to update for one change. Keep one base file with defaults, plus small per-environment files that override only what differs; at startup, deep-merge base with the active environment's file under a documented precedence rule.

config/base.json — defaults for every environment.
json
{
  "$schema": "./config.schema.json",
  "http": { "port": 8080, "timeoutMs": 5000 },
  "db": { "poolSize": 10, "ssl": false },
  "logLevel": "info"
}
config/production.json — only the deltas; everything else falls through to base.
json
{
  "db": { "poolSize": 40, "ssl": true },
  "logLevel": "warn"
}

Two rules keep merging safe. Overrides must be partial — a key missing from the environment file falls through to base, never to undefined. And arrays replace, they don't merge: trying to merge arrays element-by-index produces configurations nobody can reason about. An environment file should be small enough to read at a glance; if it isn't, the base file is holding too much.

The comments problem and honest workarounds

JSON forbids comments, so every team eventually writes them anyway and breaks a parser. The workarounds, roughly in order of preference: use JSONC or JSON5 when your toolchain reads them natively (editor settings, tsconfig.json); add a $schema key, which buys validation and editor autocomplete — documentation where comments would have gone; keep a README next to the file; and, as a last resort, dedicated "_comment" keys, which pollute the parsed object, break type generation, and accumulate forever.

What not to do: strip comments with a homegrown regex before parsing. Sooner or later it eats a # inside a legitimate string. If you control the format choice and need commented config, pick a real parser for a relaxed dialect, or move to YAML or TOML deliberately — converting an existing config with a json-to-yaml tool is a ten-minute job, not a rewrite.

Note what tsconfig.json does: it's JSONC — comments and trailing commas allowed — with an extends field for layering. When a format's most famous users quietly relax the format, take the hint and choose the relaxation deliberately instead of inheriting an accident.

Validate config in CI with JSON Schema

Config errors are runtime errors unless you catch them earlier. Write a JSON Schema for your config, reference it from the file itself with $schema so editors flag bad keys and types inline, and add a CI step that validates every environment file against the schema. A typo'd "poolSize" then fails the build instead of production at 3 a.m.

The schema pays out beyond validation: editor hover documentation, autocomplete for known keys, and diffable breaking-change detection — if CI compares the schema between main and a branch, removing a required field or narrowing a type becomes visible in review. A five-line script around a validator like ajv is enough; there's no platform to buy.

Validate the merged result, not just the files. Merge base with each environment the way the application will, then check the product against the schema — that's the only view that catches an override clobbering a nested object that base was supposed to complete.

Keep secrets out of the files

Config files end up in git, and git ends up everywhere. Secrets belong in a secret manager or environment variables, and config should hold only a reference — "secretRef": "vault:prod/db-password" — resolved by the loader at startup. The committed file stays safe to share, diff, and ship unchanged to every environment.

Apply the audit test: grep the config directory for anything resembling a credential, API key, or token; if anything matches, you fail. Then rotate anyway — a secret's copies in git history and CI logs outlive the file that briefly contained it.

What package.json and tsconfig get right

package.json survives without comments because it's an ecosystem contract rather than prose: every field is documented centrally, editors autocomplete via its well-known schema, and unknown fields are tolerated rather than fatal. The lesson generalizes — a good schema plus good editor support substitutes for inline comments better than any "_comment" hack.

tsconfig.json is JSONC — the comment is legal here — and extends layers configs exactly like environments do.
json
// Inherits defaults from tsconfig.base.json; child options deep-merge over it.
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "noEmit": true,
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

extends is layering implemented by the tool itself: compilerOptions merge from base to child, child wins, arrays replace. Make your own config loader behave identically — every TypeScript developer already carries that mental model, and consistency with it is free documentation.

FAQ

Why doesn't JSON allow comments?
Comments were stripped deliberately so parsers could be minimal and interchangeable: a comment is extra data that survives parsing in some implementations and not others, which fragments the format. For machine interchange that strictness is a feature. For hand-edited configuration it's genuinely inconvenient, which is exactly the gap JSONC and JSON5 fill.
Should I use JSONC or JSON5 for config?
JSONC is plain JSON plus comments and trailing commas — the smallest possible relaxation, and the dialect VS Code settings and tsconfig.json already use. JSON5 goes much further: unquoted keys, single quotes, hex numbers, multi-line strings. Prefer JSONC when you want to stay close to stock JSON and keep files consumable by strict tooling; reach for JSON5 when files are hand-edited constantly and you want them to read like code.
How do I validate JSON config files in CI?
Add a step that runs a JSON Schema validator — ajv in Node, jsonschema in Python — over every config file against its schema, wired to the $schema property the file already declares. Fail the build on any mismatch, and assert that every key in each environment override exists in the schema so overrides can't drift into silent no-ops.
Should I use YAML instead of JSON for config?
It's a defensible choice — comments exist and the syntax is lighter — but it isn't strictly simpler. YAML adds significant whitespace, anchors, and a dozen string-quoting edge cases that produce surprisingly different values. JSON's advantage is portability: every language parses it identically with no ambiguity. A common split is YAML for hand-edited files, strict JSON for machine-written ones.

Try these tools

Related articles