JSON Toolkit

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.

Beginner~10 min readPublished 2026-10-30Updated 2026-10-30
Managing JSON for configuration, fixtures, and local data cover

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 boolean false, 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:

  1. Add a new fixture directory (v3/); do not modify old fixtures in place;
  2. Keep the old fixtures for regression until the old API version is retired;
  3. 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/edge directories, 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.