Managing JSON for Configuration, Fixtures, and Local Data
Bottom line: Configuration must be layered and validated; fixtures must be deterministic, readable, and diffable. If these two kinds of files are managed poorly, troubleshooting costs stay high for a long time.

Bottom line: build configuration as defaults, environment, and local overrides with validation at startup before worrying about file format; make fixtures deterministic in time, IDs, key order, and indentation so they stay diffable, and only then automate masking.
These two kinds of JSON do not take part in business logic, yet they determine "how long it takes a new colleague to get running" and "how long it takes to locate a production issue".
1. Configuration: a three-layer structure
| Layer | Source | Maintained by | Committed |
|---|---|---|---|
| Defaults | Constants in code / default.json |
Developers | Yes |
| Environment configuration | config/production.json, environment variables |
Operations | Yes (without secrets) |
| Local overrides | .env.local, config/local.json |
Individuals | No (gitignored) |
Merge order: defaults → environment → local, with each later layer overriding the previous one. Merge only as deep as the business needs; do not blindly deep-merge — deep merging makes problems like "arrays got concatenated unexpectedly" extremely hard to spot.
/**
* Merges configuration layers by priority; later layers override earlier ones, and arrays are replaced wholesale rather than concatenated.
* @param layers Configuration layers from lowest to highest priority
* @returns The merged configuration object
*/
function mergeConfigLayers(layers: Array<Record<string, unknown>>): Record<string, unknown> {
return layers.reduce<Record<string, unknown>>((mergedConfig, currentLayer) => {
for (const [configKey, configValue] of Object.entries(currentLayer)) {
const isPlainObject =
typeof configValue === 'object' && configValue !== null && !Array.isArray(configValue);
const existingValue = mergedConfig[configKey];
if (isPlainObject && typeof existingValue === 'object' && existingValue !== null) {
mergedConfig[configKey] = mergeConfigLayers([
existingValue as Record<string, unknown>,
configValue as Record<string, unknown>,
]);
continue;
}
// Arrays and scalars are always replaced wholesale to avoid accidental element concatenation
mergedConfig[configKey] = configValue;
}
return mergedConfig;
}, {});
}
2. Validate at startup, do not wait until runtime
The cost of a configuration error grows exponentially with how late it is discovered:
| When it is discovered | Cost |
|---|---|
| At startup | One log line, restart and move on |
| On the first request | Some features error out, an hour of debugging |
| When a scheduled job fires | Alerts in the middle of the night |
| On settlement day | A reconciliation incident |
import Ajv2020 from 'ajv/dist/2020';
import configSchema from './config.schema.json' with { type: 'json' };
const ajv = new Ajv2020({ allErrors: true, strict: true });
/**
* Validates the configuration and throws immediately on failure, implementing fail fast.
* @param rawConfig The raw merged configuration
* @returns The validated configuration
*/
export function assertConfig(rawConfig: unknown): Record<string, unknown> {
const validate = ajv.compile(configSchema);
if (!validate(rawConfig)) {
const messages = (validate.errors ?? []).map(
(errorItem) => `${errorItem.instancePath || '/'} ${errorItem.message ?? ''}`,
);
throw new Error(`配置校验失败:\n${messages.join('\n')}`);
}
return rawConfig as Record<string, unknown>;
}
Supporting principles:
- Exit immediately when a required item is missing; do not supply a "plausible-looking" default (such as defaulting the database address to
localhost); - Exit immediately on type errors; the string
"false"is not the booleanfalse, so environment variables must be converted explicitly after being read; - Secrets never go into the repository; read them only from environment variables or a secret management service.
3. Which format to use for configuration files
| File | Format | Reason |
|---|---|---|
package.json |
Standard JSON | The package manager only accepts this |
tsconfig.json |
JSONC | Comments allowed, officially supported |
.vscode/settings.json |
JSONC | Same as above |
| Business configuration | YAML or JSONC | Needs comments explaining what each item means |
| Environment variable list | .env |
Flat key-value pairs, easy to inject |
A practical test: will this file be read by non-human tools? If yes → standard JSON; if only your own code reads it → a dialect is fine.
4. Test fixtures: directories and naming
tests/fixtures/
├─ valid/
│ ├─ order-minimal.json
│ ├─ order-full.json
│ └─ order-with-null-fields.json
├─ invalid/
│ ├─ order-missing-id.json
│ ├─ order-price-as-number.json
│ └─ order-nested-broken.json
└─ edge/
├─ big-integer.json
├─ emoji-and-surrogate.json
└─ deep-nesting-64.json
A good naming convention is object-scenario: order-missing-field is far more useful than test1.json.
Non-ASCII filenames occasionally cause encoding problems in cross-platform toolchains, so keep filenames in English while the content can stay in Chinese.
4.1 Three kinds of cases, three kinds of expectations
| Directory | Expectation | Purpose |
|---|---|---|
valid/ |
Validation passes | Main flow, type generation |
invalid/ |
Validation fails, and the error points at the expected field | Error handling, message copy |
edge/ |
Behavior is explicit (passing or failing is fine, but must be asserted) | Regression protection |
JSONTestSuite uses the y_ / n_ / i_ prefixes for the same three kinds (should accept / should reject / implementation-defined),
and you can borrow that naming convention directly.
5. Determinism: make fixtures diffable
Unstable fixtures make code review useless:
| Source of noise | How to handle it |
|---|---|
| Current time | Fix it to a constant, e.g. 2026-01-01T00:00:00Z |
| Random IDs / UUIDs | Use an incrementing sequence or fixed values |
| Key order | Sort alphabetically at generation time |
| Float formatting | Uniformly keep two decimals or convert to strings |
| Time zone | Use UTC everywhere |
| Indentation | Fixed at 2 spaces, checked by Prettier in CI |
/**
* Recursively sorts keys to produce stable, diffable JSON text.
* @param value The value to serialize
* @param indentWidth Indentation width
* @returns A JSON string with a stable order
*/
function toStableJson(value: unknown, indentWidth = 2): string {
/**
* Recursively sorts object keys.
* @param input Any JSON value
* @returns A new value with sorted keys
*/
const sortKeysDeep = (input: unknown): unknown => {
if (Array.isArray(input)) {
return input.map((item) => sortKeysDeep(item));
}
if (typeof input === 'object' && input !== null) {
return Object.fromEntries(
Object.keys(input)
.sort()
.map((entryKey) => [entryKey, sortKeysDeep((input as Record<string, unknown>)[entryKey])]),
);
}
return input;
};
return JSON.stringify(sortKeysDeep(value), null, indentWidth);
}
6. Masking and compliance
Real data cannot be used directly as fixtures:
- Names → placeholders such as "John Doe" and "Jane Roe";
- Phone numbers → obviously fictitious numbers such as
13800138000; - ID card numbers → fake numbers with the correct format but an invalid check digit;
- Addresses → keep the province/city structure, drop the house number;
- Amounts → keep the order of magnitude, but never keep real account information.
It is best for the team to have a scripts/mask-fixture.mjs that runs over an exported production sample to generate a masked version,
avoiding "change three places by hand, forget the rest".
7. Migration and versions
Fixtures go stale too. Add a schemaVersion field to the fixture, or place a VERSION file in the directory:
{
"schemaVersion": 3,
"id": "1001",
"amount": "89.50"
}
When the API contract is upgraded:
- Add a new fixture directory (
v3/); do not modify old fixtures in place; - Keep the old fixtures for regression until the old API version is retired;
- Run both versions in CI to confirm backward compatibility.
8. Checklist
- [ ] The three-layer configuration structure is clear, and local overrides are not committed;
- [ ] Validation happens at startup, and failure exits immediately;
- [ ] Secrets only come from environment variables; configuration files contain placeholders only;
- [ ] Fixtures are split into
valid/invalid/edgedirectories, and names express the scenario; - [ ] Fixtures are deterministic (fixed time, IDs, and order) and diffable;
- [ ] Real data has been processed by the masking script;
- [ ] Contract upgrades add a new version directory instead of editing in place.
FAQ
How should configuration layers be ordered and merged?
Use three layers — defaults, environment configuration, and local overrides — merging defaults → environment → local, with each later layer overriding the previous one. Merge only as deep as the business needs and replace arrays and scalars wholesale, so deep merging cannot concatenate arrays unexpectedly. Gitignore the local override file rather than committing personal settings.
Why validate configuration at startup?
The cost of a configuration error grows exponentially with how late it is discovered: one log line at startup versus a reconciliation incident on settlement day. Validate against a JSON Schema at startup and exit immediately on missing required items or type errors, instead of supplying a "plausible-looking" default.
Should I use production data directly as test fixtures?
No. Names, phone numbers, ID card numbers, addresses, and amounts all need placeholders or obviously fictitious values, and the team should have a scripts/mask-fixture.mjs that runs over a production export to generate a masked version, avoiding hand edits that miss something.