When form rules are defined outside the frontend — in an OpenAPI spec, a Python or Java backend, a CMS that lets editors build forms — rewriting them as Zod schemas means maintaining two copies that drift. JSON Schema is the language-neutral format those systems already emit, and Ajv validates it in the browser quickly.
The friction points are specific: Ajv compiles schemas with new Function, which a strict Content Security Policy blocks; its default errors are developer-oriented (must have required property 'email') and attached to the parent object for required properties; and format: "email" does nothing unless you add ajv-formats. This page, part of choosing a schema validation library, addresses each.
Context and prerequisites
Choose JSON Schema and Ajv when:
- The schema is authored elsewhere — generated from backend models, published in an OpenAPI document, or defined by a form builder.
- Several languages must agree — a Go service, a Python worker and a web form validating the same payload.
- Schemas arrive at runtime — a CMS-driven form whose fields are not known at build time.
Key Ajv options for forms:
allErrors: true— report every failure, not just the first (Ajv stops at the first by default).ajv-formats— addsemail,date,uriand others; without it,formatkeywords are ignored or rejected depending onstrictsettings.coerceTypes— converts form strings to numbers and booleans; convenient but lenient, like any coercion.- Standalone code generation — compile schemas to JavaScript modules at build time, so the browser never calls
new Function.
The core pattern: compile once, map errors to fields with readable messages
import Ajv, { type ErrorObject } from "ajv";
import addFormats from "ajv-formats";
// A schema that might come from an API or a CMS.
const contactSchema = {
type: "object",
required: ["name", "email"],
properties: {
name: { type: "string", minLength: 1, title: "your name" },
email: { type: "string", format: "email", title: "your email address" },
age: { type: "integer", minimum: 16, title: "your age" },
address: {
type: "object",
required: ["postcode"],
properties: { postcode: { type: "string", pattern: "^[A-Za-z0-9 ]{3,10}$", title: "your postcode" } },
},
},
additionalProperties: false,
} as const;
const ajv = new Ajv({ allErrors: true, strict: true, coerceTypes: false });
addFormats(ajv, ["email", "date", "uri"]);
const validate = ajv.compile(contactSchema); // compile ONCE, reuse
// "/address/postcode" → "address.postcode"; required errors point at the parent.
function fieldPath(e: ErrorObject): string {
const base = e.instancePath.split("/").slice(1).map((s) => s.replace(/~1/g, "/").replace(/~0/g, "~"));
if (e.keyword === "required") base.push(String((e.params as { missingProperty: string }).missingProperty));
return base.join(".");
}
// Find the subschema's title to name the field in messages.
function labelFor(path: string): string {
let node: any = contactSchema;
for (const key of path.split(".")) node = node?.properties?.[key];
return node?.title ?? "this field";
}
const templates: Record<string, (label: string, p: any) => string> = {
required: (l) => `Enter ${l}.`,
minLength: (l, p) => (p.limit <= 1 ? `Enter ${l}.` : `${cap(l)} must be at least ${p.limit} characters.`),
maxLength: (l, p) => `${cap(l)} must be ${p.limit} characters or fewer.`,
format: (l, p) => (p.format === "email" ? "Enter an email address like [email protected]." : `Check the format of ${l}.`),
pattern: (l) => `Check ${l}.`,
minimum: (l, p) => `${cap(l)} must be ${p.limit} or more.`,
type: (l, p) => (p.type === "integer" || p.type === "number" ? `Enter ${l} as a number.` : `Check ${l}.`),
};
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
export function validateContact(values: unknown): Record<string, string> {
if (validate(values)) return {};
const errors: Record<string, string> = {};
for (const e of validate.errors ?? []) {
if (e.keyword === "additionalProperties") continue; // a client bug, not a user error
const path = fieldPath(e);
const t = templates[e.keyword];
errors[path] ??= t ? t(labelFor(path), e.params) : `Check ${labelFor(path)}.`;
}
return errors;
}
Step-by-step walkthrough
- Compile each schema once.
ajv.compileis the expensive step; cache the resulting function per schema (by$idfor runtime schemas). - Enable
allErrorsand add formats. Forms need every error at once, andformat: "email"must actually validate. - Map
instancePathto form paths. It is a JSON Pointer; unescape~1and~0and join with dots, following normalizing nested field error paths. - Fix
requiredpaths. For missing properties Ajv reports the parent’s path; appendparams.missingPropertyso the error lands on the field. - Replace messages with templates keyed by
keyword. Use the schema’stitle(or a separate labels map) to name the field. Ajv’smessageis for developers;ajv-i18noffers translated defaults if you want a starting point. - Precompile for production when possible. Ajv’s standalone mode generates validation modules at build time, removing the runtime compiler from the bundle and the need for
unsafe-evalin CSP.
Why precompiling matters for the browser
Ajv’s speed comes from generating specialised JavaScript for each schema at runtime with new Function. In the browser, that has two costs: the compiler itself is a large part of Ajv’s bundle, and new Function requires the unsafe-eval CSP source, which security-conscious sites forbid. When schemas are known at build time — the common OpenAPI case — standalone code generation produces small, dependency-light validation modules that run under a strict CSP. Runtime compilation remains necessary only for schemas that truly arrive at runtime, such as CMS-built forms, and even then it can move to a server endpoint that returns validation results.
Failure modes and edge cases
1. additionalProperties: false rejects form extras
Forms often carry fields the API does not (a confirm-password field, UI flags). With additionalProperties: false, those produce errors. Strip UI-only fields before validating, and treat any remaining additionalProperties error as a developer bug, not a message for users.
2. Coercion and empty strings
coerceTypes: true turns "" into 0 for numbers and null-able types into null — the same trap as other coercion. Prefer converting form strings explicitly, as in coercing form strings with Zod preprocess and coerce, then validating typed data.
3. Conditional schemas
if/then/else, oneOf and anyOf produce many errors from alternative branches, most irrelevant to the user. Prefer if/then over oneOf for conditional required fields, and filter oneOf/anyOf wrapper errors when leaf errors exist.
4. Draft versions
Ajv’s default class targets draft-07; JSON Schema 2019-09 and 2020-12 need Ajv2019 or Ajv2020. OpenAPI 3.1 uses 2020-12. A schema compiled with the wrong class fails on unknown keywords under strict.
5. Unicode in patterns
JSON Schema pattern uses ECMA-262 regex syntax; Ajv compiles patterns with the u flag by default in recent versions. Patterns written for other engines (Python, PCRE) may not behave the same — test them in the browser.
Verification checklist
Frequently Asked Questions
Should I convert JSON Schema to Zod instead?
Converters exist and are useful when the schema is known at build time and you want Zod’s ergonomics in the frontend. For runtime schemas, or to guarantee exactly the same semantics as other services validating the same JSON Schema, validate the JSON Schema directly.
How do I get field labels from JSON Schema?
Use title on each property, as above, or a separate UI schema (as form builders often do) keyed by JSON Pointer. Do not derive labels from property names; postal_code makes a poor label.
Is Ajv fast enough for keystroke validation?
Compiled Ajv validators are among the fastest JavaScript validators available. The costs to watch are compilation (do it once) and error mapping on large forms; validate the changed field’s subschema where possible.