← Complete Projects

阅读中文版本

Build a Config Validator CLI

Config Validator CLI — a complete walkthrough of configuration validation, covering config loader, schema compiler, validator, and testing.

beginner~4 hconfigclivalidationschema

Project goal

Implement configuration validation that solves checking config files against a schema before deploy so a typo fails locally instead of in production, with each component independently testable.

Final features

  • Config loader
  • Schema compiler
  • Validator
  • Default filler
  • Error formatter

Architecture

The system is organized around config loader, schema compiler, validator, and default filler ties them together. Data flows as a config document validated against a JSON Schema, with defaults filled for missing optional keys and errors collected per path.

Prerequisites

  • Node.js 20 LTS or later
  • Familiarity with configuration validation fundamentals
  • A local environment to run the test suite

Dependencies

  • ajv ^8.17.0 — Schema validation
  • commander ^12.1.0 — CLI framework
  • yaml ^2.5.0 — YAML parsing

Directory structure

config_validator_cli/
├── src/index.ts
├── src/config-loader.ts
├── src/schema-compiler.ts
├── tests/config_validator_cli.test.ts
└── package.json

Phases

1. Config loader

Build the config loader that anchors configuration validation and defines the core data shape.

// Config loader: configuration validation core
export function configloader(input: ConfigloaderInput): ConfigloaderResult {
  // Build the config loader that anchors configuration validation and defines the core data shape.
  return processConfigloader(input);
}

Expected behavior: After this phase the config loader works in isolation and is covered by a unit test.

2. Schema compiler

Add the schema compiler so configuration validation can checking config files against correctly.

// Schema compiler — handles configuration validation Add the schema compiler so
export class Schemacompiler {
  constructor(private opts: SchemacompilerOptions) {}
  async run(ctx: Context): Promise<SchemacompilerOutput> {
    return this.execute(ctx);
  }
}

Expected behavior: After this phase the schema compiler works in isolation and is covered by a unit test.

3. Validator

Wire the validator to handle a schema that allows without losing data.

// Validator: configuration validation stage
export const validator = (next: Next) => async (req: Request) => {
  const out = await transform(req);
  return next(out);
};

Expected behavior: After this phase the validator works in isolation and is covered by a unit test.

4. Default filler

Integrate the default filler to make configuration validation observable and testable end to end.

// Default filler for configuration validation
export async function defaultfiller(stream: AsyncIterable<Chunk>): Promise<Aggregate> {
  let acc = init();
  for await (const c of stream) acc = merge(acc, c);
  return acc;
}

Expected behavior: After this phase the default filler works in isolation and is covered by a unit test.

5. Error formatter

Finalize the error formatter so the configuration validation system degrades gracefully under overload.

// Error formatter: configuration validation finalization
export function errorformatter(state: State): Result {
  validate(state);
  return finalize(state);
}

Expected behavior: After this phase the error formatter works in isolation and is covered by a unit test.

Testing

Each phase has a focused test: config loader, schema compiler, validator are exercised in isolation, then an integration test drives the full configuration validation pipeline and asserts the a schema that allows case is handled.

Troubleshooting

A schema that allows additional properties and lets a typo pass validation silently

Reproduce with a minimal fixture, then add a guard test so the configuration validation system rejects the condition loudly.

Performance regresses under load

Profile with the sampling analyzer, locate the hot path, and add a targeted benchmark to lock the regression.

Deployment

Run two instances behind a load balancer, share state in Redis, and set resource limits so overload surfaces as backpressure rather than failure.

Known limitations

  • Single-region; multi-region replication is out of scope.
  • The config loader is in-process and not horizontally shared by default.
  • No auth layer; assume deployment behind an edge that enforces identity.

Project overview

This project builds a configuration validation system whose reason for existing is checking config files against a schema before deploy so a typo fails locally instead of in production. The temptation in configuration validation is to bolt the interesting part onto a working prototype and discover the hard cases in production; this build inverts that order and treats a schema that allows additional properties and lets a typo pass validation silently as the first thing designed, not the last.

You will implement Config loader, Schema compiler, Validator, Default filler, and Error formatter, each as a module with one responsibility and a test that proves it in isolation. The configuration validation pipeline is then the wiring that composes them, and the integration test is where the composition is shown to survive a schema that allows additional properties and lets a typo pass validation silently.

The walkthrough is written for a developer who has used a configuration validation system but has not had to make one honest. It favors explicit data shapes over clever abstractions, and it names the failure each design choice prevents rather than presenting the choice as a style preference.

Each phase ends with something observable — a config loader that returns the right shape, a schema compiler that rejects bad input, an integration test that goes green — so you can stop at any point and still have a configuration validation system that works for the cases covered so far.

What you will build

The finished configuration validation system exposes five capabilities, each with a focused test:

  • Config loader
  • Schema compiler
  • Validator
  • Default filler
  • Error formatter

Config loader is the foundation that every later stage assumes; Schema compiler is where configuration validation correctness lives; Validator handles a schema that allows additional properties and lets a typo pass validation silently; Default filler makes the system observable; and Error formatter is the graceful-degradation layer that keeps overload from becoming corruption.

The build keeps the configuration validation logic in ordinary modules you can read in one sitting, and pulls in dependencies only for the parts that are genuinely tedious to hand-roll. That keeps the diff between the tutorial and a real codebase small, and keeps the failure modes visible in code rather than hidden behind a framework.

Architecture and data model

The system is organized around Config loader, Schema compiler, and Validator, with Default filler tying them together. Data flows as a config document validated against a JSON Schema, with defaults filled for missing optional keys and errors collected per path.

Each component is a module with a pure interface where practical: the same input produces the same output, and side effects are pushed to the edges. That discipline is what makes the phases independently testable, because a test for Validator does not need to stand up Config loader and Schema compiler to exercise the interesting branch.

The data model is deliberately explicit. A config document validated against a JSON Schema, with defaults filled for missing optional keys and errors collected per path Keeping the shape written down — in a schema, a type, or a fixture — means a change to the shape is a visible diff rather than a silent runtime shift, and it means the integration test can assert against a concrete shape instead of guessing.

Configuration and code are kept apart: the configuration says what configuration validation should do, the modules say how. A reviewer can change a threshold or a policy without reading the implementation, which keeps the risky changes small and lets the diff stand as the review.

The failure path through Validator is designed before the happy path is finished, because a schema that allows additional properties and lets a typo pass validation silently is the case that distinguishes a configuration validation system from a demo. Every component declares what it does when its inputs are wrong, and the integration test asserts each of those declarations.

Prerequisites and dependencies

Have these ready before you install anything, so the configuration validation setup is not blocked by a missing tool:

  • Node.js 20 LTS or later
  • Familiarity with configuration validation fundamentals
  • A local environment to run the test suite

The runtime dependencies are chosen to keep the configuration validation datapath legible:

  • ajv (^8.17.0) — Schema validation
  • commander (^12.1.0) — CLI framework
  • yaml (^2.5.0) — YAML parsing
# setup for config-validator-cli
pnpm init
pnpm add ajv commander yaml
pnpm add -D typescript tsx vitest

Versions are pinned to a major range so the configuration validation install is reproducible; commit the lockfile. The config loader reads its operational parameters from the environment and validates them at startup, so a missing value fails the boot rather than the first configuration validation request.

Directory structure

Keep each configuration validation component in its own file so it can be tested without starting the others:

config_validator_cli/
├── src/index.ts
├── src/config-loader.ts
├── src/schema-compiler.ts
├── tests/config_validator_cli.test.ts
└── package.json

Phase 1: Config loader

Build the config loader that anchors configuration validation and defines the core data shape.

// Config loader: configuration validation core
export function configloader(input: ConfigloaderInput): ConfigloaderResult {
  // Build the config loader that anchors configuration validation and defines the core data shape.
  return processConfigloader(input);
}

After this phase the config loader works in isolation and is covered by a unit test. The config loader defines the data shape that every later stage assumes, so getting it right first means the later phases compile against a stable contract.

Treat the config loader as the single source of truth for the configuration validation data shape. Validate inputs at the boundary and reject early, so a malformed value never reaches Schema compiler or Validator where its effect would be harder to localize.

Phase 2: Schema compiler

Add the schema compiler so configuration validation can checking config files against correctly.

// Schema compiler — handles configuration validation Add the schema compiler so
export class Schemacompiler {
  constructor(private opts: SchemacompilerOptions) {}
  async run(ctx: Context): Promise<SchemacompilerOutput> {
    return this.execute(ctx);
  }
}

After this phase the schema compiler works in isolation and is covered by a unit test. This is where configuration validation correctness lives: the schema compiler is what makes checking config files against a schema before deploy so a typo fails locally instead of in production true, and its test is the proof.

Keep the schema compiler pure where you can. A function that takes an input and returns an output is trivial to test and impossible to mock wrong; a class with hidden state is the opposite. Push side effects to the caller and keep the schema compiler about the transformation.

Phase 3: Validator

Wire the validator to handle a schema that allows without losing data.

// Validator: configuration validation stage
export const validator = (next: Next) => async (req: Request) => {
  const out = await transform(req);
  return next(out);
};

After this phase the validator works in isolation and is covered by a unit test. The validator is the difference between a configuration validation system that works on the happy path and one that survives a schema that allows additional properties and lets a typo pass validation silently.

Design the failure before the success. Decide what validator does when a schema that allows additional properties and lets a typo pass validation silently: reject, retry, shed, or degrade — and write that test first. A configuration validation system whose failure behavior is unspecified will pick one at random under pressure, and it is usually the wrong one.

Phase 4: Default filler

Integrate the default filler to make configuration validation observable and testable end to end.

// Default filler for configuration validation
export async function defaultfiller(stream: AsyncIterable<Chunk>): Promise<Aggregate> {
  let acc = init();
  for await (const c of stream) acc = merge(acc, c);
  return acc;
}

After this phase the default filler works in isolation and is covered by a unit test. Observability is not a finishing touch; it is how you know the configuration validation system is doing what it claims in production.

Emit one structured event per meaningful action, with enough context to localize a failure without a second round-trip. The default filler is what turns “something is slow” into “the schema compiler is slow for inputs matching this shape,” which is the difference between an alert and an investigation.

Phase 5: Error formatter

Finalize the error formatter so the configuration validation system degrades gracefully under overload.

// Error formatter: configuration validation finalization
export function errorformatter(state: State): Result {
  validate(state);
  return finalize(state);
}

After this phase the error formatter works in isolation and is covered by a unit test. The error formatter is what keeps overload from becoming corruption: when the configuration validation system is asked for more than it can do, it degrades in a defined way instead of failing in an undefined one.

Pick a degradation strategy for error formatter — shed, queue, or backpressure — and make it explicit in code and in a test. Leaving the behavior to chance is how a configuration validation traffic spike becomes a data loss incident.

Testing

Each phase has a focused test: config loader, schema compiler, and validator are exercised in isolation, then an integration test drives the full configuration validation pipeline and asserts the a schema that allows case is handled.

Start with unit tests that pin each component’s contract: the config loader returns the documented shape, the schema compiler transforms correctly, and the validator fails in the documented way. These tests are fast and they localize regressions to the component that broke.

Then add an integration test that composes the components and drives a realistic configuration validation scenario end to end. Assert the happy path first, then deliberately trigger a schema that allows additional properties and lets a typo pass validation silently and assert the system degrades rather than corrupts. Because the components are pure where practical, the integration test stays deterministic rather than flaky.

A configuration validation suite must also prove the negative: bad input is rejected at the boundary, overload is shed rather than queued indefinitely, and a partial failure does not leave the system in an inconsistent state. These are the tests that catch the regressions a happy-path suite never would.

The entire configuration validation suite runs with no external service; every dependency is either in-process or stubbed, so every case is reproducible in CI. That is what lets the validator failure-mode assertions live as tests rather than as a manual checklist that drifts out of date.

# run the configuration validation test suite
pnpm vitest run

Deployment and operations

Run at least two configuration validation instances behind a load balancer and share state in Redis. Set resource limits so configuration validation overload surfaces as backpressure rather than failure.

Run at least two stateless instances behind a load balancer, and move any shared state into a shared store so the configuration validation behavior is global rather than per instance. Size limits so the system sheds load before it runs out of memory, because a configuration validation system that OOMs under load fails in a way that is hard to reason about.

Drain on deploy: stop accepting new work, let in-flight configuration validation operations finish, then exit. Because the system holds no state that cannot be reconstructed, a rollout is a matter of cycling instances one at a time while watching the error rate.

When a dependency is unavailable, decide explicitly whether the configuration validation system fails closed or degrades. Leaving that choice to chance is how a dependency outage becomes a silent data loss, so make the choice visible in code and in a test.

Security considerations

Validate all configuration validation inputs at the boundary, reject oversized payloads before buffering them, and keep secrets in the environment rather than the repository.

The configuration validation system is a choke point, so it is the right place to reject malformed input before it reaches schema compiler or validator. Validate the shape and the size at the edge, and let the inner components assume what they receive is well-formed.

Never log secrets or full payloads; log enough to localize a failure without recording data that should not be on disk. Treat any external call as untrusted and validate its response before using it, because a configuration validation system that trusts its inputs blindly is one malformed response away from corruption.

Keep dependencies pinned and run an audit in CI. A transitive dependency with a known vulnerability is still a vulnerability in your configuration validation system, and the lockfile is what makes the audit meaningful.

Observability

Emit one structured event per configuration validation operation with the inputs’ shape, the outcome, and a correlation id. Track error rate and latency per component and alert on a step change rather than a single spike.

The correlation id is the thread that ties a user-visible symptom to the configuration validation decision and to the component log entry for the same operation. Without it, an investigation starts from a timestamp and a guess.

Watch the validator failure rate separately from the overall error rate. A jump in validator failures usually means a schema that allows additional properties and lets a typo pass validation silently, not a bug in schema compiler, and the two have different owners and different fixes.

Chart the share of configuration validation operations that shed, queue, or succeed. A change in the mix points at the cause faster than an aggregate latency can, because each class has a different root.

Troubleshooting

A schema that allows additional properties and lets a typo pass validation silently. Reproduce with a minimal fixture, then add a guard test so the configuration validation system rejects the condition loudly.

Performance regresses under configuration validation load. Profile with the sampling analyzer, locate the hot path in schema compiler, and add a targeted benchmark to lock the regression.

Keep the configuration validation dashboards separate from dependency dashboards. When an operation fails, the correlation id lets you decide in seconds whether the fault is config loader, schema compiler, or validator, instead of guessing from a timeout.

Limitations and next steps

  • Single-region; multi-region replication is out of scope.
  • The config loader is in-process and not horizontally shared by default.
  • No auth layer; assume deployment behind an edge that enforces identity.

The configuration validation system is single-region by design; extending to multi-region means making config loader shareable across regions, which is a real change to the consistency model rather than a config flag. The validator is in-process, which is correct for one instance but must move to a shared store before scaling out.

There is no authentication or authorization layer; the system assumes it is deployed behind an edge that enforces identity. Adding one is straightforward but out of scope for this build, and doing it here would distract from the configuration validation correctness that is the point.

The natural next step, once the configuration validation pipeline is trusted in production, is to add a short-TTL cache for idempotent reads and a richer default filler that exports histograms rather than counters. Both are additive and neither changes the contracts this build establishes.