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 integer

Use 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 runtime

The 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, and pattern that 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. The ajv-formats plugin adds format validators (email, uri, date-time, uuid); ajv-errors adds the errorMessage keyword for custom error strings; ajv-i18n rewrites 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" and minLength: 1 compiles to a function containing if (typeof data !== "string" || data.length < 1) return false. Compilation happens once via const 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 the type field determines which schema to validate against — more efficient than a plain z.union() because Zod checks only the matching branch rather than trying all. The safeParse() return type is also a discriminated union: { success: true, data: T } | { success: false, error: ZodError } — TypeScript knows that result.data is only accessible when result.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 provides safeParse() (returns discriminated union, never throws), parse() (throws ZodError on failure), and safeParseAsync() for async validation with refine(). Zod handles transformations alongside validation — z.string().trim().toLowerCase() both validates and transforms the input. Install with npm 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. In json-schema-to-ts: type User = FromSchema<typeof schema> does the same for a const JSON 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 the errorMessage keyword to JSON Schema. When AJV generates an error for a keyword (e.g., minLength), ajv-errors checks if the schema at that location has an errorMessage entry for that keyword and replaces the generated error object with one containing your custom message. Requires new Ajv({ allErrors: true }) — without allErrors, 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 DocumentationOfficial AJV documentation — API reference, schema compilation, custom keywords, and performance tips
  • ajv-errors pluginGitHub repository for ajv-errors — custom error messages with the errorMessage keyword
  • Zod DocumentationOfficial Zod documentation — schema definition, safeParse, transformations, and TypeScript inference
  • json-schema-to-typescriptnpm package and CLI to generate TypeScript interfaces from JSON Schema files
  • JSON Schema SpecificationOfficial JSON Schema specification site — Draft-07, 2019-09, and 2020-12 documentation