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

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

Teams change configuration more often than any other part of a system and test it least. JSON works well for it until someone wants a comment, a second environment, or a secret in it. This playbook covers JSON config: layering environments, the comments problem and its 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

Don't fork the whole config per environment. Three copies of mostly-identical JSON give you 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 instead of merging, because merging arrays element by index produces configurations nobody can reason about. You should be able to read an environment file at a glance. If you can't, the base file holds too much.

The comments problem and its 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 gives you validation and editor autocomplete as documentation where comments would have gone. Keep a README next to the file. As a last resort, use dedicated "_comment" keys, which pollute the parsed object, break type generation, and pile up forever.

Don't strip comments with a homegrown regex before parsing. Sooner or later it eats a # inside a legitimate string. If you control the format and need commented config, pick a real parser for a relaxed dialect, or move to YAML or TOML on purpose. Converting an existing config with a json-to-yaml tool takes ten minutes.

tsconfig.json uses JSONC, with comments and trailing commas allowed, plus an extends field for layering. When a format's most famous users relax the format, take the hint and choose the relaxation on purpose instead of inheriting an accident.

Validate config in CI with JSON Schema

Config errors become runtime errors unless you catch them earlier. Write a JSON Schema for your config, reference it from the file 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 off beyond validation: editor hover documentation, autocomplete for known keys, and breaking-change detection you can diff. If CI compares the schema between main and a branch, reviewers see a removed required field or a narrowed type. A five-line script around a validator like ajv covers it, with no platform to buy.

Validate the merged result as well as the files. Merge base with each environment the way the application will, then check the product against the schema. Only that view catches an override that clobbers a nested object base was supposed to complete.

Keep secrets out of the files

Config files end up in git, and git ends up everywhere. Put secrets in a secret manager or environment variables, and let config hold only a reference ("secretRef": "vault:prod/db-password") that the loader resolves at startup. You can then share, diff, and ship the committed file unchanged to every environment.

Run the audit test: grep the config directory for anything resembling a credential, API key, or token. Any match means you fail. Rotate the secret anyway, because its copies in git history and CI logs outlive the file that briefly held it.

What package.json and tsconfig get right

package.json survives without comments because it works as an ecosystem contract, not prose: a central reference documents every field, editors autocomplete via its well-known schema, and tools tolerate unknown fields instead of failing. The lesson carries over. A good schema plus good editor support replaces inline comments better than any "_comment" hack.

tsconfig.json is JSONC, so the comment is legal here, and extends layers configs the same way 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 built into the tool: compilerOptions merge from base to child, child wins, arrays replace. Make your own config loader behave the same way. Every TypeScript developer already carries that mental model, so matching it documents your loader for free.

FAQ

Why doesn't JSON allow comments?
Crockford removed comments on purpose so parsers could stay minimal and interchangeable. Some implementations would keep a comment through parsing and others would drop it, which fragments the format. For machine interchange that strictness helps. For hand-edited configuration it gets in the way, and JSONC and JSON5 fill that gap.
Should I use JSONC or JSON5 for config?
JSONC is plain JSON plus comments and trailing commas: the smallest relaxation, and the dialect VS Code settings and tsconfig.json already use. JSON5 goes much further with unquoted keys, single quotes, hex numbers, and multi-line strings. Prefer JSONC to stay close to stock JSON and keep files readable by strict tooling; reach for JSON5 when people edit the files by hand all the time 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 the schema its $schema property declares. Fail the build on any mismatch, and assert that every key in each environment override exists in the schema, so a misspelled override can't turn into a no-op nobody notices.
Should I use YAML instead of JSON for config?
You can defend that choice, since YAML has comments and a lighter syntax, but it isn't simpler. YAML adds significant whitespace, anchors, and a dozen string-quoting edge cases that produce surprising values. JSON's advantage is portability: every language parses it the same way with no ambiguity. Many teams split it: YAML for hand-edited files, strict JSON for machine-written ones.

Try these tools

Related articles