which fully supports the draft-2020-12 specification.
Step 1: Define the Contract
Start by declaring the expected payload structure. Avoid embedding schemas directly in route handlers. Instead, isolate them in a dedicated directory. This enables reuse across frontend forms, backend controllers, and CI validation scripts.
// schemas/payment-instruction.schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "payment-instruction-v1",
"type": "object",
"required": ["transactionId", "recipient", "amount", "currency"],
"additionalProperties": false,
"properties": {
"transactionId": {
"type": "string",
"pattern": "^txn_[a-f0-9]{24}$",
"description": "Internal transaction reference"
},
"recipient": {
"type": "object",
"required": ["accountId", "routingCode"],
"properties": {
"accountId": { "type": "string", "minLength": 8, "maxLength": 16 },
"routingCode": { "type": "string", "pattern": "^\\d{9}$" }
}
},
"amount": {
"type": "number",
"exclusiveMinimum": 0,
"multipleOf": 0.01
},
"currency": {
"type": "string",
"enum": ["USD", "EUR", "GBP", "JPY"]
},
"metadata": {
"type": "object",
"properties": {
"reference": { "type": "string", "maxLength": 50 },
"priority": { "type": "string", "enum": ["standard", "express"] }
},
"additionalProperties": false
}
}
}
Architectural Rationale:
additionalProperties: false prevents silent acceptance of unexpected fields, which often indicate client-side bugs or API version mismatches.
exclusiveMinimum: 0 ensures zero-value transactions are rejected at the boundary, avoiding downstream accounting errors.
multipleOf: 0.01 enforces cent-level precision for financial payloads, eliminating floating-point drift.
$id enables schema referencing and version tracking across services.
JSON Schema validation involves regex evaluation, type checking, and nested traversal. Running these operations on every request without optimization causes latency spikes under load. The solution is pre-compilation. ajv transforms the schema into an optimized JavaScript function that executes validation logic directly, bypassing interpretation overhead.
// validators/schema-compiler.ts
import Ajv from "ajv";
import addFormats from "ajv-formats";
import paymentSchema from "../schemas/payment-instruction.schema.json";
const ajv = new Ajv({
strict: true,
coerceTypes: "array",
validateFormats: true,
allErrors: true,
useDefaults: true,
});
addFormats(ajv);
// Pre-compile and cache
const validatePayment = ajv.compile(paymentSchema);
export { validatePayment };
Architectural Rationale:
strict: true catches schema authoring mistakes during compilation rather than at runtime.
coerceTypes: "array" safely converts single values to arrays when the schema expects a list, reducing client-side formatting friction.
validateFormats: true enforces email, uri, date-time, and other format keywords, which ajv disables by default for performance.
allErrors: true collects every violation in a single pass, enabling comprehensive error reporting instead of failing on the first mismatch.
Step 3: Integrate into the Request Pipeline
Validation should occur at the system boundary, before data reaches business logic. In an Express-style framework, this translates to middleware that intercepts the request, runs the compiled validator, and transforms failures into standardized responses.
// middleware/validate-payload.ts
import { Request, Response, NextFunction } from "express";
import { validatePayment } from "../validators/schema-compiler";
export function validatePaymentMiddleware(
req: Request,
res: Response,
next: NextFunction
): void {
const isValid = validatePayment(req.body);
if (!isValid) {
const errors = validatePayment.errors?.map((err) => ({
path: err.instancePath || "$",
message: err.message,
constraint: err.keyword,
}));
res.status(422).json({
status: "validation_failed",
details: errors,
});
return;
}
next();
}
Architectural Rationale:
- Returning
422 Unprocessable Entity aligns with HTTP semantics for well-formed but semantically invalid payloads.
- Mapping
instancePath to a dot-notation or JSON pointer format gives frontend teams precise feedback on which field failed.
- Separating validation from route logic keeps controllers focused on orchestration, not data hygiene.
Step 4: Synchronize Types Across the Stack
JSON Schema becomes exponentially more valuable when it generates TypeScript interfaces automatically. This eliminates the drift between runtime validation and compile-time types. Tools like json-schema-to-typescript or quicktype can generate interfaces directly from the schema file, ensuring that frontend forms, backend DTOs, and test fixtures share a single source of truth.
Pitfall Guide
1. Ignoring additionalProperties
Explanation: Leaving this keyword undefined allows arbitrary fields to pass validation. Malformed payloads or deprecated client versions slip through, causing silent data corruption or unexpected behavior in downstream services.
Fix: Explicitly set additionalProperties: false for strict contracts. If extensibility is required, use patternProperties to allow only specific dynamic keys.
2. Overusing oneOf and anyOf
Explanation: Composition keywords trigger exponential validation complexity. oneOf requires the validator to test against every branch and ensure exactly one matches, which degrades performance and produces confusing error messages when multiple branches partially match.
Fix: Prefer discriminators or flatten schemas where possible. If composition is unavoidable, order branches from most specific to least specific, and add explicit type constraints to each branch to reduce backtracking.
Explanation: The JSON Schema specification treats format as an annotation by default. ajv will not enforce email, uri, or date-time constraints unless explicitly configured.
Fix: Enable validateFormats: true during Ajv instantiation, or register custom format validators for domain-specific patterns like phone numbers or IBANs.
4. Relying on TypeScript Interfaces for Runtime Safety
Explanation: TypeScript types are erased during compilation. A payload that passes type checking in development can still contain missing fields or wrong types in production.
Fix: Treat JSON Schema as the authoritative contract. Generate TypeScript interfaces from the schema rather than writing them manually. This guarantees runtime and compile-time alignment.
5. Validating Only on the Client Side
Explanation: Client-side validation improves UX but provides zero security. API endpoints must always validate incoming data, as requests can bypass frontend code entirely via curl, Postman, or compromised clients.
Fix: Implement schema validation at the API gateway or service boundary. Client validation should mirror the server schema to provide consistent feedback, but never replace server-side checks.
6. Failing to Pre-Compile Schemas
Explanation: Compiling a schema on every request forces the validator to parse JSON, build regex patterns, and construct validation logic repeatedly. This introduces measurable latency under concurrent load.
Fix: Compile schemas once during application startup. Store the resulting functions in a module-level cache or dependency injection container. Benchmark shows pre-compilation reduces validation time by 80-90%.
7. Neglecting Schema Versioning
Explanation: Modifying a schema without versioning breaks existing clients. Adding a required field to an existing contract immediately invalidates older payloads.
Fix: Use semantic versioning in the $id field (e.g., payment-instruction-v2). Maintain backward compatibility by making new fields optional initially, then deprecate old versions through a migration window. Store historical schemas in a version-controlled registry.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-throughput public API | Pre-compiled ajv with strict: true | Minimizes latency, catches authoring errors early | Low runtime cost, moderate dev setup |
| Internal microservice communication | Shared schema registry with generated DTOs | Eliminates contract drift, enables automated testing | Higher initial tooling investment, lower long-term maintenance |
| Configuration file validation | Build-time schema checking in CI pipeline | Prevents deployment of malformed configs | Zero runtime overhead, faster failure detection |
| Database document enforcement | Native MongoDB JSON Schema validation | Reduces application-layer validation, ensures data integrity at rest | Slight query overhead, simplified backend logic |
| Rapid prototyping / MVP | Inline schema with ajv + coerceTypes | Fast iteration, forgiving type conversion | Higher technical debt, requires refactoring before production |
Configuration Template
// src/infrastructure/validation-engine.ts
import Ajv from "ajv";
import addFormats from "ajv-formats";
import addErrors from "ajv-errors";
export class ValidationEngine {
private readonly ajv: Ajv;
constructor() {
this.ajv = new Ajv({
strict: true,
coerceTypes: "array",
validateFormats: true,
allErrors: true,
useDefaults: true,
removeAdditional: "all",
});
addFormats(this.ajv);
addErrors(this.ajv);
}
public compile(schema: object): Ajv.ValidateFunction {
return this.ajv.compile(schema);
}
public formatErrors(errors: Ajv.ErrorObject[] | null): Array<{
path: string;
message: string;
constraint: string;
}> {
if (!errors) return [];
return errors.map((err) => ({
path: err.instancePath || "$",
message: err.message || "Unknown constraint violation",
constraint: err.keyword,
}));
}
}
Quick Start Guide
- Install dependencies: Run
npm install ajv ajv-formats ajv-errors to add the validator and format support to your project.
- Create a schema file: Define your payload contract in a
.json file using the draft-2020-12 specification. Include type, required, and constraint keywords.
- Initialize the engine: Instantiate
Ajv with strict: true and validateFormats: true. Pre-compile your schema during application startup.
- Attach to your pipeline: Wrap the compiled validator in middleware or a service method. Return
422 with field-level error details when validation fails.
- Generate types: Run
npx json-schema-to-typescript src/schemas/*.json > src/types/generated.ts to synchronize runtime validation with compile-time safety.