JSON Schema Validation JavaScript: AJV v8, Zod & Custom Errors
Last updated:
JSON Schema validation checks whether a JSON document conforms to a schema — AJV (Another JSON Validator) compiles schemas to JavaScript functions, validating 10 million objects per second on a typical laptop. AJV v8 supports JSON Schema Draft-07, 2019-09, and 2020-12; install with npm install ajv (30 KB gzipped). Zod provides runtime validation with TypeScript inference in one package, while Yup targets browser form validation.
This guide covers AJV setup and schema compilation, required fields and type checking, custom error messages with ajv-errors, async validation, Zod as a JSON Schema alternative, and TypeScript type inference from schemas. Every example includes error message handling. See also our JSON Schema guide and JSON data validation overview.
AJV Setup: Installing and Compiling Your First JSON Schema
AJV compiles a JSON Schema into a native JavaScript function once — every subsequent validation call invokes that function directly with zero schema-parsing overhead. This is the core reason AJV outperforms interpreting validators by 20x. Install AJV v8 with npm install ajv, then import, instantiate, compile, and call the validator. The compiled function is reusable and thread-safe.
// ── Install ───────────────────────────────────────────────────────
// npm install ajv
import Ajv from "ajv";
// ── Instantiate AJV ───────────────────────────────────────────────
const ajv = new Ajv({
allErrors: true, // collect ALL errors, not just the first
strict: true, // warn on unknown keywords (recommended)
});
// ── Define a JSON Schema ──────────────────────────────────────────
const userSchema = {
type: "object",
properties: {
name: { type: "string", minLength: 1 },
email: { type: "string", format: "email" },
age: { type: "integer", minimum: 0, maximum: 150 },
role: { type: "string", enum: ["admin", "editor", "viewer"] },
},
required: ["name", "email", "age"],
additionalProperties: false, // reject unknown keys
};
// ── Compile once — reuse many times ──────────────────────────────
const validateUser = ajv.compile(userSchema);
// ── Valid data ────────────────────────────────────────────────────
const validUser = { name: "Alice", email: "alice@example.com", age: 30 };
console.log(validateUser(validUser)); // true
console.log(validateUser.errors); // null
// ── Invalid data — collect all errors ────────────────────────────
const badUser = { name: "", age: -1, role: "superadmin", extra: "x" };
console.log(validateUser(badUser)); // false
console.log(validateUser.errors);
// [
// { instancePath: "/name", keyword: "minLength", message: "must NOT have fewer than 1 characters" },
// { instancePath: "", keyword: "required", message: 'must have required property 'email'' },
// { instancePath: "/age", keyword: "minimum", message: "must be >= 0" },
// { instancePath: "/role", keyword: "enum", message: "must be equal to one of the allowed values" },
// { instancePath: "", keyword: "additionalProperties", message: 'must NOT have additional properties' },
// ]
// ── Format validation requires ajv-formats ────────────────────────
// npm install ajv-formats
import addFormats from "ajv-formats";
const ajvWithFormats = new Ajv({ allErrors: true });
addFormats(ajvWithFormats);
// Now "format": "email", "uri", "date", "date-time", "uuid" etc. are enforced
// ── TypeScript: validate as a type guard ──────────────────────────
interface User {
name: string;
email: string;
age: number;
role?: "admin" | "editor" | "viewer";
}
function isUser(data: unknown): data is User {
return validateUser(data);
}
const input: unknown = JSON.parse('{"name":"Bob","email":"b@b.com","age":25}');
if (isUser(input)) {
console.log(input.name); // TypeScript knows: string
}Pass allErrors: true to the Ajv constructor to collect all validation errors in a single pass — without it, AJV stops at the first error. This is almost always what you want for user-facing APIs where callers need to fix every error at once, not one at a time. Enable strict: true (the default in AJV v8) to get warnings about schemas with unknown keywords — typos like "minimun" instead of "minimum" will throw rather than silently do nothing. The format keyword requires the separate ajv-formats package; AJV v8 does not bundle format validators by default to keep the core small.
Type Validation: string, number, boolean, null, array, object
JSON Schema supports six primitive types: string, number, integer, boolean, null, array, and object. Each type has its own set of validation keywords. Understanding which keywords apply to which types prevents schema bugs where a keyword silently has no effect — for example, minLength on a number property is ignored rather than an error in non-strict mode.
import Ajv from "ajv";
import addFormats from "ajv-formats";
const ajv = new Ajv({ allErrors: true });
addFormats(ajv);
// ── string ────────────────────────────────────────────────────────
const stringSchema = {
type: "string",
minLength: 1, // reject empty strings
maxLength: 255, // cap length
pattern: "^[a-zA-Z0-9_-]+$", // regex — alphanumeric + _ -
format: "email", // requires ajv-formats
};
// ── number / integer ──────────────────────────────────────────────
const numberSchema = {
type: "number", // accepts 1, 1.5, -3.14
minimum: 0, // inclusive lower bound
maximum: 100, // inclusive upper bound
exclusiveMinimum: 0, // strictly greater than 0 (Draft-07+)
multipleOf: 0.01, // must be a multiple (e.g. for currency)
};
const intSchema = {
type: "integer", // rejects 1.5 — must be a whole number
minimum: 1,
maximum: 1000,
};
// ── boolean ───────────────────────────────────────────────────────
const boolSchema = { type: "boolean" }; // only true / false — not 0/1
// ── null ──────────────────────────────────────────────────────────
const nullableString = {
type: ["string", "null"], // accepts string OR null
minLength: 1,
};
// ── array ────────────────────────────────────────────────────────
const arraySchema = {
type: "array",
items: {
type: "object",
properties: {
id: { type: "integer" },
name: { type: "string", minLength: 1 },
},
required: ["id", "name"],
},
minItems: 1,
maxItems: 50,
uniqueItems: true, // deep equality check — no duplicates
};
// ── object ────────────────────────────────────────────────────────
const objectSchema = {
type: "object",
properties: {
id: { type: "integer" },
meta: { type: "object", additionalProperties: { type: "string" } },
// ^ meta accepts any string-valued keys
},
required: ["id"],
additionalProperties: false, // block unknown top-level keys
minProperties: 1,
maxProperties: 20,
};
// ── multi-type field ──────────────────────────────────────────────
const flexSchema = {
type: ["string", "number", "null"], // string, number, OR null
};
// ── const — exact value ───────────────────────────────────────────
const constSchema = { const: "published" }; // only "published" is valid
// ── enum — fixed set of values (any JSON type) ───────────────────
const enumSchema = { enum: ["draft", "published", "archived", null, 42] };
// ── Validate examples ─────────────────────────────────────────────
const validateArray = ajv.compile(arraySchema);
validateArray([{ id: 1, name: "Widget" }, { id: 2, name: "Gadget" }]); // true
validateArray([]); // false — minItems: 1
validateArray([{ id: "x", name: "A" }]); // false — id must be integerUse type: "integer" instead of type: "number" whenever the value must be a whole number — AJV rejects 1.5 for integer schemas, preventing ID fields and counts from accepting fractional values. For nullable fields, use type: ["string", "null"] (an array of types) rather than a oneOf with two branches — it is more concise and compiles to a faster validator. The additionalProperties: false keyword is one of the most valuable constraints for API input validation: it rejects any key not declared in properties, which prevents clients from passing unexpected data that could cause bugs or security issues downstream.
Required Fields, Minimum Length, and Pattern Constraints
The required keyword checks for key presence — it does not validate the value. An empty string passes required. Combine required with minLength: 1 in the property definition to catch both missing and empty values. For structured string formats (slugs, phone numbers, postal codes), pattern provides regex validation without the overhead of a format plugin.
import Ajv from "ajv";
const ajv = new Ajv({ allErrors: true });
// ── required + minLength — the correct combination ────────────────
const signupSchema = {
type: "object",
properties: {
username: {
type: "string",
minLength: 3, // reject empty AND too-short strings
maxLength: 50,
pattern: "^[a-zA-Z0-9_]+$", // alphanumeric + underscore only
},
password: {
type: "string",
minLength: 8,
maxLength: 128,
},
email: {
type: "string",
minLength: 1,
// use ajv-formats for true email validation
},
age: {
type: "integer",
minimum: 18, // must be an adult
},
},
required: ["username", "password", "email"], // presence check
additionalProperties: false,
};
const validate = ajv.compile(signupSchema);
// Missing required field
validate({ password: "secret123", email: "a@b.com" }); // false
// [{ keyword: "required", message: "must have required property 'username'" }]
// Required field present but empty
validate({ username: "", password: "secret123", email: "a@b.com" }); // false
// [{ instancePath: "/username", keyword: "minLength" }]
// Pattern violation
validate({ username: "alice smith", password: "secret123", email: "a@b.com" }); // false
// [{ instancePath: "/username", keyword: "pattern" }] — space not allowed
// All valid
validate({ username: "alice_99", password: "secret123", email: "a@b.com" }); // true
// ── Conditional required (dependentRequired) ──────────────────────
// If "companyName" is present, "vatNumber" is also required
const invoiceSchema = {
type: "object",
properties: {
companyName: { type: "string" },
vatNumber: { type: "string", pattern: "^[A-Z]{2}[0-9]{9}$" },
contactName: { type: "string", minLength: 1 },
},
required: ["contactName"],
dependentRequired: {
companyName: ["vatNumber"], // if companyName present → vatNumber required
},
};
// ── if/then/else — conditional validation ─────────────────────────
const shippingSchema = {
type: "object",
properties: {
method: { type: "string", enum: ["domestic", "international"] },
country: { type: "string" },
zip: { type: "string", pattern: "^[0-9]{5}$" },
customs: { type: "string" },
},
required: ["method"],
if: { properties: { method: { const: "international" } } },
then: { required: ["country", "customs"] }, // extra fields for international
else: { required: ["zip"] }, // zip only for domestic
};
// ── Multiple patterns with anyOf ──────────────────────────────────
const phoneSchema = {
anyOf: [
{ type: "string", pattern: "^\+[1-9][0-9]{6,14}$" }, // E.164 international
{ type: "string", pattern: "^[0-9]{10}$" }, // 10-digit US
],
};The dependentRequired keyword (available in Draft 2019-09 and 2020-12, and backported to AJV v8 even for Draft-07 schemas) is cleaner than if/then for simple field-dependency rules: if property A is present, then property B is also required. Use if/then/else when the conditional logic is more complex — different sets of required fields based on a discriminator value like method: "international". For regex patterns, note that JSON Schema patterns are implicitly anchored only if you include ^ and $ — without them, the pattern matches anywhere in the string, which can produce surprising results.
Custom Error Messages with ajv-errors and ajv-i18n
AJV's default error messages are accurate but terse — "must NOT have fewer than 1 characters" is not suitable for end users. The ajv-errors plugin adds an errorMessage keyword to JSON Schema that replaces generated errors with your own strings. For multi-language applications, ajv-i18n rewrites error messages to 33 supported locales in place, without modifying schemas.
// ── Install ───────────────────────────────────────────────────────
// npm install ajv ajv-errors ajv-formats
import Ajv from "ajv";
import addErrors from "ajv-errors";
import addFormats from "ajv-formats";
// allErrors: true is REQUIRED for ajv-errors
const ajv = new Ajv({ allErrors: true });
addFormats(ajv);
addErrors(ajv);
// ── Schema with errorMessage per keyword ──────────────────────────
const passwordSchema = {
type: "object",
properties: {
password: {
type: "string",
minLength: 8,
maxLength: 128,
pattern: "(?=.*[A-Z])(?=.*[0-9])", // at least one uppercase + digit
errorMessage: {
type: "Password must be a string",
minLength: "Password must be at least 8 characters",
maxLength: "Password must not exceed 128 characters",
pattern: "Password must contain at least one uppercase letter and one digit",
},
},
email: {
type: "string",
format: "email",
errorMessage: {
type: "Email must be a string",
format: "Please enter a valid email address",
},
},
},
required: ["password", "email"],
errorMessage: {
required: {
password: "Password is required",
email: "Email address is required",
},
},
};
const validate = ajv.compile(passwordSchema);
validate({ email: "not-valid", password: "abc" }); // false
console.log(validate.errors?.map(e => e.message));
// [
// "Password must be at least 8 characters",
// "Password must contain at least one uppercase letter and one digit",
// "Please enter a valid email address",
// ]
// ── String-form errorMessage — replaces ALL errors for a node ─────
const simpleSchema = {
type: "object",
properties: {
age: {
type: "integer",
minimum: 18,
maximum: 120,
errorMessage: "Age must be a whole number between 18 and 120",
// ^ one message for any type/minimum/maximum error on this field
},
},
};
// ── ajv-i18n: rewrite messages to another locale ──────────────────
// npm install ajv-i18n
import Ajv2 from "ajv";
import localize from "ajv-i18n"; // default export = English (same as AJV)
import localizeDE from "ajv-i18n/localize/de"; // German
import localizePT from "ajv-i18n/localize/pt"; // Portuguese
const ajv2 = new Ajv2({ allErrors: true });
const validateI18n = ajv2.compile({
type: "object",
properties: { name: { type: "string", minLength: 1 } },
required: ["name"],
});
validateI18n({}); // false
localizeDE(validateI18n.errors); // rewrites errors to German IN PLACE
console.log(validateI18n.errors?.[0]?.message);
// "muß die erforderliche Eigenschaft 'name' enthalten"
// ── Extract user-friendly error map ──────────────────────────────
function getErrorMap(errors: typeof validateI18n.errors) {
const map: Record<string, string> = {};
for (const err of errors ?? []) {
const field = err.instancePath.replace(/^//, "") || err.params?.missingProperty || "_root";
map[field] ??= err.message ?? "Invalid value";
}
return map;
}
validate({ email: "bad", password: "weak" });
console.log(getErrorMap(validate.errors));
// { password: "Password must be at least 8 characters", email: "Please enter a valid email address" }The errorMessage keyword replaces matching AJV error objects in validate.errors with custom-message error objects that have the same shape — your downstream error-handling code does not need to change. Note that allErrors: true is a hard requirement for ajv-errors; the plugin does not work correctly when AJV stops at the first error. The getErrorMap helper above is a practical pattern for REST APIs: convert the validate.errors array into a flat object keyed by field name, which maps cleanly to form field errors in React or any other framework.
Async Validation and Database Uniqueness Checks
AJV supports async schemas for validations that require I/O — checking whether an email is already registered in a database, verifying a coupon code against an external service, or confirming a username is available. Async validators are defined with $async: true at the schema root and custom async keywords registered via ajv.addKeyword(). The compiled validator returns a Promise instead of a boolean.
import Ajv from "ajv";
const ajv = new Ajv({ allErrors: true });
// ── Register a custom async keyword ──────────────────────────────
ajv.addKeyword({
keyword: "uniqueEmail",
async: true,
validate: async function validateUniqueEmail(schema: boolean, email: string) {
if (!schema) return true; // skip if uniqueEmail: false
// Simulate a database lookup
const existing = await db.users.findOne({ email });
if (existing) {
// Throw Ajv.ValidationError to produce proper error objects
throw new Ajv.ValidationError([
{
instancePath: "",
schemaPath: "#/properties/email/uniqueEmail",
keyword: "uniqueEmail",
params: { keyword: "uniqueEmail" },
message: "This email address is already registered",
},
]);
}
return true;
},
});
// ── Async schema ──────────────────────────────────────────────────
const registrationSchema = {
$async: true, // marks the compiled validator as async
type: "object",
properties: {
email: {
type: "string",
uniqueEmail: true, // our custom async keyword
},
username: {
type: "string",
minLength: 3,
},
},
required: ["email", "username"],
};
const validateAsync = ajv.compile(registrationSchema);
// ── Usage: await the promise ───────────────────────────────────────
async function registerUser(data: unknown) {
try {
const valid = await validateAsync(data);
if (valid) {
// proceed with registration
}
} catch (err) {
if (err instanceof Ajv.ValidationError) {
console.log(err.errors);
// [{ keyword: "uniqueEmail", message: "This email address is already registered" }]
} else {
throw err; // unexpected error — rethrow
}
}
}
// ── Mixed sync + async validation ────────────────────────────────
// Run sync AJV first (fast), then async checks only if sync passes
async function validateWithDbCheck(data: unknown) {
// 1. Fast sync validation
const syncValid = validateSync(data); // compiled without $async
if (!syncValid) {
return { valid: false, errors: validateSync.errors };
}
// 2. Slow async check — only reached if sync passes
try {
await validateAsync(data);
return { valid: true, errors: null };
} catch (err) {
if (err instanceof Ajv.ValidationError) {
return { valid: false, errors: err.errors };
}
throw err;
}
}
// ── Custom keyword: async coupon code check ───────────────────────
ajv.addKeyword({
keyword: "validCoupon",
async: true,
schema: false, // the keyword takes no schema value — it's a flag
validate: async function checkCoupon(_schema: unknown, code: string) {
const coupon = await couponService.lookup(code);
if (!coupon || coupon.expired) {
throw new Ajv.ValidationError([{
instancePath: "",
schemaPath: "#/validCoupon",
keyword: "validCoupon",
params: {},
message: "Coupon code is invalid or expired",
}]);
}
return true;
},
});Always run synchronous AJV validation before async checks — it is orders of magnitude faster and eliminates obviously invalid data without any I/O. Only fire database queries for data that passes structural validation. When throwing from a custom async keyword, use new Ajv.ValidationError([...]) with a well-formed error object array — this keeps the error shape consistent with the rest of validate.errors and avoids special-casing in error handlers. In Express or Fastify, wrap the async validate call in a try/catch in middleware and return 422 Unprocessable Entity for Ajv.ValidationError and 500 for other errors.
Zod: TypeScript-First Schema Validation
Zod defines schemas in TypeScript code and infers static types from them automatically — there is no separate JSON Schema file and no code generation step. schema.safeParse(data) returns a discriminated union that never throws: { success: true, data } on success and { success: false, error } on failure. This makes error handling exhaustive and type-safe. Install with npm install zod.
// npm install zod
import { z } from "zod";
// ── Define a schema ───────────────────────────────────────────────
const UserSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email address"),
age: z.number().int().min(18, "Must be 18 or older"),
role: z.enum(["admin", "editor", "viewer"]).optional(),
tags: z.array(z.string()).max(10).optional(),
metadata: z.record(z.string()).optional(), // { [key: string]: string }
});
// ── Infer the TypeScript type — zero duplication ──────────────────
type User = z.infer<typeof UserSchema>;
// Equivalent to:
// type User = {
// name: string;
// email: string;
// age: number;
// role?: "admin" | "editor" | "viewer";
// tags?: string[];
// metadata?: Record<string, string>;
// }
// ── safeParse — never throws ──────────────────────────────────────
const result = UserSchema.safeParse({
name: "Alice",
email: "alice@example.com",
age: 30,
});
if (result.success) {
console.log(result.data.name); // TypeScript: string ✓
} else {
console.log(result.error.issues);
// [{ path: [], code: "invalid_type", message: "..." }]
}
// ── Flatten errors to field map ───────────────────────────────────
function getZodErrors(result: z.SafeParseError<unknown>) {
return result.error.flatten().fieldErrors;
// { name: ["Name is required"], email: ["Invalid email address"] }
}
// ── parse — throws ZodError on failure ───────────────────────────
try {
const user = UserSchema.parse({ name: "", email: "bad", age: 15 });
} catch (err) {
if (err instanceof z.ZodError) {
console.log(err.flatten().fieldErrors);
// { name: ["String must contain at least 1 character(s)"],
// email: ["Invalid email address"],
// age: ["Must be 18 or older"] }
}
}
// ── Transformations ───────────────────────────────────────────────
const TrimmedUser = z.object({
name: z.string().min(1).trim(), // trim whitespace after validation
email: z.string().email().toLowerCase(), // lowercase after validation
dob: z.string().transform(s => new Date(s)), // parse date string
});
type TrimmedUser = z.infer<typeof TrimmedUser>;
// { name: string; email: string; dob: Date }
// ── Async validation ──────────────────────────────────────────────
const AsyncUserSchema = z.object({
username: z.string().min(3).refine(
async (username) => {
const exists = await db.users.exists({ username });
return !exists;
},
{ message: "Username is already taken" }
),
});
const asyncResult = await AsyncUserSchema.safeParseAsync({ username: "alice" });
// ── Discriminated union — type-safe variant schemas ───────────────
const EventSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("click"), x: z.number(), y: z.number() }),
z.object({ type: z.literal("scroll"), delta: z.number() }),
z.object({ type: z.literal("focus"), elementId: z.string() }),
]);
type Event = z.infer<typeof EventSchema>;Prefer safeParse over parse in application code — it makes error handling explicit in the type system without try/catch. Use parse only at trusted boundaries (config file loading at startup) where an invalid value should crash the process immediately. The flatten().fieldErrors method on a ZodError is the single most useful Zod utility: it converts the nested issue tree into a flat { fieldName: string[] } map that matches directly to form validation UIs in React Hook Form, Formik, and similar libraries. See our TypeScript JSON types guide for more on working with typed JSON.
Inferring TypeScript Types from JSON Schema with json-schema-to-typescript
When you already have JSON Schema files — from OpenAPI specs, database schemas, or external contracts — json-schema-to-typescript generates TypeScript interfaces from them via CLI or API. The json-schema-to-ts package provides a FromSchema utility type that infers the TypeScript type at compile time without any code generation, directly from a const schema object.
// ── Option 1: json-schema-to-typescript (code generation) ─────────
// npm install -D json-schema-to-typescript
// CLI usage — generate once, commit the output:
// npx json2ts schema.json > src/types/schema.d.ts
// npx json2ts --input schemas/ --output src/types/
// Programmatic usage (build script):
import { compile } from "json-schema-to-typescript";
import fs from "fs/promises";
const schema = {
title: "User",
type: "object",
properties: {
id: { type: "integer" },
name: { type: "string", minLength: 1 },
email: { type: "string", format: "email" },
role: { type: "string", enum: ["admin", "editor", "viewer"] },
},
required: ["id", "name", "email"],
additionalProperties: false,
};
const ts = await compile(schema as any, "User", {
bannerComment: "", // omit the auto-generated comment
style: { singleQuote: true },
});
// Generates:
// export interface User {
// id: number;
// name: string;
// email: string;
// role?: 'admin' | 'editor' | 'viewer';
// }
await fs.writeFile("src/types/user.d.ts", ts);
// ── Option 2: json-schema-to-ts (compile-time, no codegen) ────────
// npm install json-schema-to-ts
import type { FromSchema } from "json-schema-to-ts";
const userSchema = {
type: "object",
properties: {
id: { type: "integer" },
name: { type: "string" },
email: { type: "string" },
role: { type: "string", enum: ["admin", "editor", "viewer"] as const },
},
required: ["id", "name", "email"],
additionalProperties: false,
} as const; // 'as const' is required for FromSchema to work
type User = FromSchema<typeof userSchema>;
// {
// id: number;
// name: string;
// email: string;
// role?: "admin" | "editor" | "viewer";
// }
// ── AJV + FromSchema: compile-time types + runtime validation ──────
import Ajv from "ajv";
const ajv = new Ajv();
const validate = ajv.compile<FromSchema<typeof userSchema>>(userSchema);
// validate is now typed as: (data: unknown) => data is User
function processUser(raw: unknown) {
if (validate(raw)) {
// raw is now typed as User — no cast needed
console.log(raw.name.toUpperCase()); // string ✓
console.log(raw.id.toFixed(0)); // number ✓
} else {
console.log(validate.errors);
}
}
// ── OpenAPI schema extraction workflow ────────────────────────────
// 1. Have openapi.yaml with components/schemas/User
// 2. Run: npx openapi-typescript openapi.yaml -o src/types/openapi.d.ts
// 3. Import: import type { components } from "./types/openapi";
// type User = components["schemas"]["User"];
// 4. Use the same JSON Schema for AJV validation at runtimeThe as const assertion on the schema object is required for FromSchema to work — without it, TypeScript widens string literals to string, losing enum information. The compile-time approach (json-schema-to-ts) is preferable for schemas that live in code and change frequently — no build step or committed generated files to keep in sync. The code-generation approach (json-schema-to-typescript) is preferable for external schemas (OpenAPI specs, JSON files) where you want the generated types visible and auditable in the repository. Combine either approach with AJV for a complete solution: FromSchema gives you the static type, ajv.compile gives you the runtime validator that narrows unknown to that type. See our JSON data types reference for the full type system.
Key Terms
- JSON Schema
- A vocabulary that describes the structure and constraints of JSON documents. A JSON Schema is itself a JSON object (or boolean) containing keywords like
type,properties,required,minimum, andpatternthat define what valid data looks like. JSON Schema is language-agnostic — the same schema file can validate data in JavaScript (AJV), Python (jsonschema), Go (gojsonschema), and any other language with a validator. The current specification versions are Draft-07 (most widely supported), Draft 2019-09, and Draft 2020-12. JSON Schema is the foundation for OpenAPI request/response validation, form validation libraries, and database document validation. See our JSON Schema guide for the full keyword reference. - AJV
- Another JSON Validator — the fastest JSON Schema validator for JavaScript, validating ~10 million objects per second. AJV compiles each JSON Schema into a native JavaScript function using code generation, so validation runs as plain JS with no schema-parsing overhead after the first compile. Install with
npm install ajv. AJV v8 (the current major version) supports Draft-07, 2019-09, and 2020-12. Theajv-formatsplugin adds format validators (email, uri, date-time, uuid);ajv-errorsadds theerrorMessagekeyword for custom error strings;ajv-i18nrewrites error messages to 33 locales. AJV is used internally by Fastify for route schema validation and by many other Node.js frameworks. - schema compilation
- The process by which AJV converts a JSON Schema object into a JavaScript function via code generation. During compilation, AJV reads the schema keywords and generates optimized JavaScript source code that performs the equivalent validation logic — for example, a schema with
type: "string"andminLength: 1compiles to a function containingif (typeof data !== "string" || data.length < 1) return false. Compilation happens once viaconst validate = ajv.compile(schema)and the resulting function is reused indefinitely. The compiled function is a plain JavaScript closure — it can be stored, passed around, and called from multiple threads. This is why AJV is 20x faster than interpreting validators that re-parse the schema on every validation call. - discriminated union
- A TypeScript type pattern (and Zod feature) where a union of types is tagged with a shared literal property that uniquely identifies each variant. In Zod,
z.discriminatedUnion("type", [...])creates a union where thetypefield determines which schema to validate against — more efficient than a plainz.union()because Zod checks only the matching branch rather than trying all. ThesafeParse()return type is also a discriminated union:{ success: true, data: T } | { success: false, error: ZodError }— TypeScript knows thatresult.datais only accessible whenresult.success === true, making the pattern exhaustive without try/catch. - Zod
- A TypeScript-first schema validation library that defines schemas in TypeScript code and infers static types from them via
z.infer<typeof schema>. Unlike JSON Schema, Zod schemas are not serializable — they are JavaScript objects with methods, not plain data. Zod providessafeParse()(returns discriminated union, never throws),parse()(throwsZodErroron failure), andsafeParseAsync()for async validation withrefine(). Zod handles transformations alongside validation —z.string().trim().toLowerCase()both validates and transforms the input. Install withnpm install zod. Version 4 (Zod v4) significantly improved performance and TypeScript inference speed for large schemas. - type inference
- The ability to derive a TypeScript type from a schema definition without writing the type separately. In Zod:
type User = z.infer<typeof UserSchema>extracts the exact TypeScript type the schema validates. Injson-schema-to-ts:type User = FromSchema<typeof schema>does the same for aconstJSON Schema object. Type inference eliminates the most common source of type drift — manually maintaining a TypeScript interface that should match a validation schema but diverges over time when one is updated and the other is not. With inference, the schema is the single source of truth: change the schema, and the TypeScript type updates automatically at compile time. - ajv-errors
- An AJV plugin (
npm install ajv-errors) that adds theerrorMessagekeyword to JSON Schema. When AJV generates an error for a keyword (e.g.,minLength),ajv-errorschecks if the schema at that location has anerrorMessageentry for that keyword and replaces the generated error object with one containing your custom message. Requiresnew Ajv({ allErrors: true })— withoutallErrors, the plugin cannot correctly map errors to their replacements. Supports per-keyword overrides ({ minLength: "...", pattern: "..." }), string overrides (one message for all errors), and required-property overrides ({ required: { fieldName: "..." } }). The output error objects have the same shape as standard AJV errors.
FAQ
What is the fastest JSON Schema validator for JavaScript?
AJV (Another JSON Validator) is the fastest JSON Schema validator for JavaScript, benchmarking at approximately 10 million validations per second on a typical laptop — about 20x faster than interpreted validators like jsonschema. AJV achieves this by compiling each schema into a native JavaScript function via code generation. The compiled function runs as plain JavaScript with zero schema-parsing overhead after the first ajv.compile(schema) call. Install with npm install ajv. For maximum throughput, compile all schemas once at application startup and reuse the compiled validators across requests. AJV v8 (the current version) supports JSON Schema Draft-07, 2019-09, and 2020-12. For even faster validation of TypeScript-typed data, AJV also supports JSON Type Definition (JTD) — a simpler alternative to JSON Schema with stricter semantics that compiles to even more minimal functions.
How do I set up AJV for JSON Schema validation?
Install AJV with npm install ajv, then follow four steps: (1) Import and instantiate: import Ajv from "ajv"; const ajv = new Ajv({ allErrors: true }). (2) Define your JSON Schema as a plain object with type, properties, required, and any constraints. (3) Compile once: const validate = ajv.compile(schema). (4) Call the compiled function: const valid = validate(data) — it returns true or false, and validate.errors contains the error array when false. For email, URI, and date format validation, also install ajv-formats and call addFormats(ajv) before compiling. For TypeScript, pass the inferred type as a generic: ajv.compile<User>(schema) to get a typed type guard. Compile schemas at module load time — not inside request handlers — to avoid re-compilation overhead.
How do I add custom error messages to AJV validation?
Install ajv-errors alongside AJV: npm install ajv ajv-errors. Instantiate AJV with allErrors: true (required), apply the plugin with addErrors(ajv), then add an errorMessage keyword to any schema node. Use a string to replace all errors for that node: { type: "string", minLength: 1, errorMessage: "Name is required" }. Use an object for per-keyword overrides: { type: "string", minLength: 8, errorMessage: { type: "Must be a string", minLength: "Must be at least 8 characters" } }. For required field messages, add errorMessage at the object level: { errorMessage: { required: { email: "Email is required" } } }. The plugin replaces matching AJV error objects in validate.errors with your custom messages — downstream error-handling code is unchanged. For multi-language support, use ajv-i18n instead: localizeDE(validate.errors) rewrites all messages to German in place.
What is the difference between AJV and Zod for JSON validation?
AJV validates data against a JSON Schema — a language-agnostic plain JSON object. JSON Schemas can be shared across services, generated from OpenAPI specs, and stored in databases. AJV is the fastest option at ~10 million ops/second and is ideal when you already have JSON Schema definitions or need to validate against external contracts. Zod defines schemas in TypeScript code (z.object({ name: z.string() })) and infers static TypeScript types from those schemas via z.infer — no separate type declarations and no code generation. Zod is ideal when you own the schema, work exclusively in TypeScript, and want one source of truth for both runtime validation and compile-time types. Zod's safeParse() never throws; AJV's compiled function returns a boolean and populates validate.errors. For browser form validation, Yup is a third option with a fluent API and promise-based async validation designed for Formik and React Hook Form integration.
How do I validate required fields in JSON Schema?
Add a required array at the object level listing the property names that must be present: { "type": "object", "properties": { "email": { "type": "string" } }, "required": ["email"] }. This validates that the key exists — it does not validate the value. An object with { "email": "" } passes the required check. To also reject empty strings, add minLength: 1 inside the property definition: { "email": { "type": "string", "minLength": 1 } }. To block unknown keys, add additionalProperties: false. For conditional required fields — if field A is present, field B is also required — use dependentRequired (AJV v8 supports this for all schema drafts): { "dependentRequired": { "companyName": ["vatNumber"] } }. In Zod, all fields are required by default; use .optional() to allow absence and .min(1) to reject empty strings.
How do I infer TypeScript types from a JSON Schema?
Two approaches: (1) Compile-time inference with json-schema-to-ts — install npm install json-schema-to-ts, define the schema as const, and use type User = FromSchema<typeof schema>. TypeScript infers the exact type at compile time, no code generation needed. (2) Code generation with json-schema-to-typescript — run npx json2ts schema.json > types.d.ts to generate a TypeScript interface file from any JSON Schema file, including external ones you do not own. For AJV integration, use ajv.compile<FromSchema<typeof schema>>(schema) to get a typed type guard function — after if (validate(data)), TypeScript narrows data to the inferred type. For Zod, type User = z.infer<typeof UserSchema> extracts the type directly. See our TypeScript JSON types guide for more patterns.
How do I validate JSON arrays with AJV?
Set type: "array" and use the items keyword to define the schema for each element. For a homogeneous array of objects: { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "integer" } }, "required": ["id"] }, "minItems": 1, "maxItems": 100 }. minItems and maxItems constrain array length. uniqueItems: true rejects arrays with duplicate elements using deep equality. For tuple validation (fixed-length array with typed positions), use prefixItems in Draft 2020-12: { "type": "array", "prefixItems": [{ "type": "string" }, { "type": "number" }], "items": false } — items: false rejects elements beyond the prefix. In Draft-07, use items as an array for the same effect. In Zod: z.array(z.object({ id: z.number() })).min(1).max(100) — and z.tuple([z.string(), z.number()]) for fixed-position tuples.
How do I use AJV with JSON Schema Draft 2020-12?
Import the Draft 2020-12 Ajv class from its subpath: import Ajv2020 from "ajv/dist/2020". The API is identical — const ajv = new Ajv2020({ allErrors: true }), then ajv.compile(schema) and validate(data) as usual. Draft 2020-12 changes to be aware of: prefixItems replaces the array-form of items for tuple validation; items in 2020-12 applies only to array elements beyond the prefix items; $ref can now appear alongside other keywords (in Draft-07, sibling keywords were ignored when $ref was present); unevaluatedProperties and unevaluatedItems provide more precise control than additionalProperties for schemas using allOf/anyOf. For Draft 2019-09, use import Ajv2019 from "ajv/dist/2019". The default import Ajv from "ajv" supports Draft-07 only.
Further reading and primary sources
- AJV Documentation — Official AJV documentation — API reference, schema compilation, custom keywords, and performance tips
- ajv-errors plugin — GitHub repository for ajv-errors — custom error messages with the errorMessage keyword
- Zod Documentation — Official Zod documentation — schema definition, safeParse, transformations, and TypeScript inference
- json-schema-to-typescript — npm package and CLI to generate TypeScript interfaces from JSON Schema files
- JSON Schema Specification — Official JSON Schema specification site — Draft-07, 2019-09, and 2020-12 documentation