JSON Data Validation: AJV vs Zod vs Yup vs Valibot, Multi-Layer Strategy
Last updated:
JSON data validation ensures incoming data matches your expected shape before processing — preventing runtime errors, security vulnerabilities, and data corruption at the API boundary. A missing required field or a string where an integer is expected can propagate silently until it triggers a runtime exception deep inside a data pipeline.
The four most popular JavaScript validation libraries are AJV (standard JSON Schema validation, 10M validations/sec), Zod (TypeScript-first, schema → type inference), Yup (browser form validation, Promise-based), and Valibot (1.9 KB gzipped, modular tree-shaking). No single library dominates all use cases.
This guide covers multi-layer validation strategy (client, server, database), library comparison with benchmarks, required vs optional field patterns, union types for polymorphic JSON, and international error messages. Every example compiles with TypeScript strict mode. For foundational JSON Schema syntax, see our dedicated guide.
Multi-Layer Validation: Where to Validate JSON
Validate JSON at every trust boundary — the points where data crosses from an external or less-trusted context into your system. Defense in depth means a validation failure at one layer is caught rather than silently corrupting data downstream. Three primary boundaries exist in a full-stack application: client form, API server endpoint, and database.
Client layer (browser/mobile): Validate before submission to give users immediate feedback and avoid unnecessary network round-trips. Use Zod or Yup with React Hook Form or Formik. Client validation is UX-only — never trust it at the server because it can be bypassed entirely.
// ── Client-side: React Hook Form + Zod resolver ──────────────────
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const CheckoutSchema = z.object({
email: z.string().email({ message: 'Enter a valid email address' }),
quantity: z.number().int().min(1).max(100),
coupon: z.string().optional(),
})
type Checkout = z.infer<typeof CheckoutSchema>
function CheckoutForm() {
const { register, handleSubmit, formState: { errors } } = useForm<Checkout>({
resolver: zodResolver(CheckoutSchema),
})
return (
<form onSubmit={handleSubmit(data => fetch('/api/checkout', { method: 'POST', body: JSON.stringify(data) }))}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="number" {...register('quantity', { valueAsNumber: true })} />
{errors.quantity && <span>{errors.quantity.message}</span>}
<button type="submit">Order</button>
</form>
)
}
// ── Server layer: Express middleware with Zod ─────────────────────
import { Request, Response, NextFunction } from 'express'
import { ZodSchema } from 'zod'
function validateBody<T>(schema: ZodSchema<T>) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body)
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
issues: result.error.issues.map(i => ({
field: i.path.join('.'),
message: i.message,
code: i.code,
})),
})
}
req.body = result.data // replace raw body with typed, validated data
next()
}
}
app.post('/api/checkout', validateBody(CheckoutSchema), async (req, res) => {
const order: Checkout = req.body // fully typed — validation already passed
await createOrder(order)
res.json({ success: true })
})
// ── Database layer: PostgreSQL CHECK constraint ───────────────────
-- Enforce JSON structure at storage level (last line of defense)
ALTER TABLE orders
ADD CONSTRAINT orders_payload_check
CHECK (
payload ? 'email' AND -- key must exist
payload ? 'quantity' AND
jsonb_typeof(payload->'quantity') = 'number'
);The server layer is the most critical. It validates data from all sources — browser forms, mobile apps, API clients, webhooks — and is the only boundary you fully control. Database constraints are the safety net for any data that bypasses the application layer (direct SQL inserts, migrations, bulk imports). Always implement all three layers for production systems handling user data.
AJV: JSON Schema Validation at 10M ops/sec
AJV (Another JSON Validator) compiles JSON Schema to an optimized JavaScript function at startup — the compiled function runs at near-native speed with no schema traversal overhead per call. For bulk processing, Kafka consumer pipelines, or ETL jobs that validate millions of records, AJV is the correct choice. Compile once; call the compiled function in the hot path.
import Ajv from 'ajv/dist/2020' // JSON Schema Draft 2020-12
import addFormats from 'ajv-formats'
import addErrors from 'ajv-errors'
// ── Setup: compile ONCE at module load ────────────────────────────
const ajv = new Ajv({ allErrors: true, strict: true })
addFormats(ajv) // adds: email, date-time, uri, uuid, ipv4, ipv6 …
addErrors(ajv) // adds: errorMessage keyword for custom messages
const UserSchema = {
type: 'object',
properties: {
id: { type: 'integer', minimum: 1 },
email: { type: 'string', format: 'email' },
role: { type: 'string', enum: ['admin', 'editor', 'viewer'] },
score: { type: 'number', minimum: 0, maximum: 100 },
tags: { type: 'array', items: { type: 'string' }, maxItems: 20 },
meta: { type: 'object', additionalProperties: { type: 'string' } },
},
required: ['id', 'email', 'role'],
additionalProperties: false, // reject keys not in properties
errorMessage: { // custom messages via ajv-errors
required: {
id: 'User ID is required',
email: 'Email address is required',
role: 'User role must be specified',
},
properties: {
email: 'Must be a valid email address',
score: 'Score must be between 0 and 100',
},
},
}
const validate = ajv.compile(UserSchema) // compiled once
// ── Hot path: call the compiled validator ─────────────────────────
function validateUser(data: unknown): { valid: boolean; errors: unknown } {
const valid = validate(data)
return { valid, errors: validate.errors ?? [] }
}
// ── Bulk validation: 10M objects/sec ─────────────────────────────
const results = users.map(user => {
const ok = validate(user)
return ok ? null : validate.errors
}).filter(Boolean)
console.log(`${results.length} invalid records out of ${users.length}`)
// ── Ajv with TypeScript: addSchema for reuse ─────────────────────
ajv.addSchema(UserSchema, 'User')
ajv.addSchema({
type: 'object',
properties: {
userId: { '$ref': 'User#/properties/id' }, // reuse from User schema
amount: { type: 'number', exclusiveMinimum: 0 },
product: { type: 'string', minLength: 1, maxLength: 200 },
},
required: ['userId', 'amount', 'product'],
additionalProperties: false,
}, 'Order')
const validateOrder = ajv.getSchema('Order')!The strict: true Ajv option (default in Ajv 8) rejects schemas with unknown keywords and warns about patterns that are likely mistakes — for example, defining properties without additionalProperties when extra keys should be forbidden. Disable it only when consuming third-party schemas you cannot modify. The $ref mechanism lets you build a schema library where complex types are defined once and reused across schemas, keeping validation logic DRY across a large codebase.
Zod: TypeScript-First Validation with Type Inference
Zod defines schemas as TypeScript expressions and derives static types automatically via z.infer<typeof Schema>. The schema and the type are the same source of truth — they cannot drift apart. z.safeParse() returns a discriminated union and never throws, making it safe to call at every API boundary without try/catch wrappers. This is the strongest reason to choose Zod for TypeScript JSON types in full-stack applications.
import { z } from 'zod'
// ── Define schema — TypeScript type inferred automatically ─────────
const AddressSchema = z.object({
street: z.string().min(1).max(200),
city: z.string().min(1).max(100),
country: z.string().length(2), // ISO 3166-1 alpha-2
zip: z.string().regex(/^[A-Z0-9 -]{3,10}$/, 'Invalid postal code'),
})
const UserSchema = z.object({
id: z.number().int().positive(),
email: z.string().email(),
firstName: z.string().min(1).max(50),
lastName: z.string().min(1).max(50),
address: AddressSchema, // nested schema
role: z.enum(['admin', 'editor', 'viewer']),
score: z.number().min(0).max(100).optional(),
tags: z.array(z.string()).max(20).default([]),
createdAt: z.string().datetime(), // ISO 8601 datetime string
})
type User = z.infer<typeof UserSchema> // TypeScript type for free
// ── safeParse: never throws — always returns a result ─────────────
function parseUser(raw: unknown): User | null {
const result = UserSchema.safeParse(raw)
if (!result.success) {
// result.error.issues: array of { path, code, message }
console.error('Validation errors:', result.error.issues)
return null
}
return result.data // typed as User
}
// ── Error shape from safeParse ────────────────────────────────────
const bad = UserSchema.safeParse({ id: -1, email: 'not-an-email' })
if (!bad.success) {
bad.error.issues.forEach(issue => {
console.log(issue.path.join('.'), issue.code, issue.message)
// "id" "too_small" "Number must be greater than 0"
// "email" "invalid_string" "Invalid email"
// "firstName" "invalid_type" "Required"
// ...
})
// Nested format for form field mapping
const formatted = bad.error.format()
// { id: { _errors: ["..."] }, email: { _errors: ["..."] }, ... }
}
// ── Transform and coerce input ────────────────────────────────────
const CoercedUserSchema = z.object({
id: z.coerce.number().int().positive(), // "42" → 42
score: z.coerce.number().optional(),
tags: z.preprocess(
(val) => (typeof val === 'string' ? val.split(',') : val),
z.array(z.string())
),
})
// ── Partial and pick for PATCH endpoints ─────────────────────────
const UpdateUserSchema = UserSchema.partial() // all fields optional
const UserPreviewSchema = UserSchema.pick({ id: true, email: true, role: true })Use z.coerce when accepting data from query strings or form fields where numbers arrive as strings. Use z.preprocess for custom input normalization before type checking. UserSchema.partial() generates the schema for PATCH endpoints where all fields are optional — without duplicating the field definitions. z.infer on a partial schema gives you Partial<User> automatically.
Yup and Valibot: Alternatives for Specific Use Cases
Yup and Valibot fill specific niches where Zod and AJV are not the best fit. Yup excels at async validation with database lookups — its Promise-based API integrates naturally with Formik and supports abortEarly: false to collect all field errors at once. Valibot excels when bundle size is a hard constraint — its modular, tree-shakeable architecture produces a fraction of Zod's bundle footprint.
// ── Yup: async validation (database uniqueness check) ────────────
import * as yup from 'yup'
const RegistrationSchema = yup.object({
username: yup
.string()
.required('Username is required')
.min(3, 'At least 3 characters')
.max(30, 'At most 30 characters')
.test('unique-username', 'Username already taken', async (value) => {
if (!value) return false
const exists = await db.users.exists({ username: value })
return !exists // return true = valid, false = invalid
}),
email: yup
.string()
.email('Must be a valid email')
.required('Email is required'),
age: yup.number().integer().min(18, 'Must be 18 or older').required(),
terms: yup.boolean().oneOf([true], 'You must accept the terms'),
})
try {
// abortEarly: false collects ALL errors — essential for forms
const valid = await RegistrationSchema.validate(formData, { abortEarly: false })
} catch (err) {
if (err instanceof yup.ValidationError) {
// err.inner: array of individual ValidationError per field
const fieldErrors = err.inner.reduce((acc, e) => {
if (e.path) acc[e.path] = e.message
return acc
}, {} as Record<string, string>)
// { username: "Username already taken", terms: "You must accept the terms" }
}
}
// ── Valibot: minimal bundle size (~1.9 KB gzipped) ───────────────
import * as v from 'valibot'
// Each validator is a separate import — only what you use is bundled
const EmailSchema = v.pipe(
v.string(),
v.nonEmpty('Email is required'),
v.email('Must be a valid email address'),
v.maxLength(255, 'Email too long'),
)
const UserSchema = v.object({
id: v.pipe(v.number(), v.integer(), v.minValue(1)),
email: EmailSchema,
role: v.picklist(['admin', 'editor', 'viewer']),
score: v.optional(v.pipe(v.number(), v.minValue(0), v.maxValue(100))),
tags: v.array(v.string()),
})
type User = v.InferOutput<typeof UserSchema> // TypeScript type inferred
// safeParse equivalent in Valibot
const result = v.safeParse(UserSchema, rawData)
if (result.success) {
const user: User = result.output
} else {
result.issues.forEach(issue => {
console.log(issue.path?.map(p => p.key).join('.'), issue.message)
})
}
// ── Library comparison table ──────────────────────────────────────
// Library | Size (gzip) | Speed | TypeScript | Async | Best for
// AJV | 27 KB | ~10M ops/sec | Manual | No | Bulk, OpenAPI
// Zod | 13 KB | ~300K ops/sec| Inferred | No | TypeScript APIs
// Yup | 11 KB | ~150K ops/sec| Manual | Yes | Forms w/ async
// Valibot | 1.9 KB | ~250K ops/sec| Inferred | No | Edge, mobileValibot 1.x introduced a pipeline-based composition model (v.pipe(v.string(), v.email(), ...)) where each validator in the pipeline runs in sequence and can transform the value. This is more explicit than Zod's method chaining but enables tree-shaking at the individual validator level — a form that only uses v.string() and v.email() does not bundle number or array validators at all. For edge runtimes (Cloudflare Workers, Vercel Edge) with strict 1 MB size limits, Valibot's size advantage is decisive.
Validating Union Types and Discriminated Unions in JSON
Polymorphic JSON — where the shape of an object depends on the value of one field — requires union type validation. A discriminated union uses a shared literal field (called the discriminant, typically type or kind) to identify which schema variant applies. This pattern is common in event-driven systems, webhook payloads, and API responses that return multiple entity types.
// ── Zod: discriminatedUnion (faster, better errors than z.union) ──
import { z } from 'zod'
const ShapeSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('circle'),
radius: z.number().positive(),
}),
z.object({
type: z.literal('rectangle'),
width: z.number().positive(),
height: z.number().positive(),
}),
z.object({
type: z.literal('triangle'),
base: z.number().positive(),
altitude: z.number().positive(),
}),
])
type Shape = z.infer<typeof ShapeSchema>
function area(shape: Shape): number {
switch (shape.type) {
case 'circle': return Math.PI * shape.radius ** 2
case 'rectangle': return shape.width * shape.height
case 'triangle': return 0.5 * shape.base * shape.altitude
}
}
// ── Webhook event routing with discriminated union ─────────────────
const WebhookEventSchema = z.discriminatedUnion('event', [
z.object({
event: z.literal('order.created'),
orderId: z.string().uuid(),
amount: z.number().positive(),
items: z.array(z.object({ sku: z.string(), qty: z.number().int() })),
}),
z.object({
event: z.literal('order.cancelled'),
orderId: z.string().uuid(),
reason: z.string().max(500),
refundAmt: z.number().nonnegative().optional(),
}),
z.object({
event: z.literal('user.created'),
userId: z.string().uuid(),
email: z.string().email(),
plan: z.enum(['free', 'pro', 'enterprise']),
}),
])
app.post('/webhook', (req, res) => {
const result = WebhookEventSchema.safeParse(req.body)
if (!result.success) return res.status(400).json({ error: 'Invalid event' })
const event = result.data // narrowed to the specific union member
switch (event.event) {
case 'order.created': return handleOrderCreated(event) // event.orderId, event.amount, event.items
case 'order.cancelled': return handleOrderCancelled(event) // event.orderId, event.reason
case 'user.created': return handleUserCreated(event) // event.userId, event.email
}
})
// ── AJV: discriminated union with JSON Schema oneOf ───────────────
const ShapeJsonSchema = {
oneOf: [
{
type: 'object',
properties: {
type: { const: 'circle' },
radius: { type: 'number', exclusiveMinimum: 0 },
},
required: ['type', 'radius'],
additionalProperties: false,
},
{
type: 'object',
properties: {
type: { const: 'rectangle' },
width: { type: 'number', exclusiveMinimum: 0 },
height: { type: 'number', exclusiveMinimum: 0 },
},
required: ['type', 'width', 'height'],
additionalProperties: false,
},
],
}Prefer z.discriminatedUnion() over z.union() in Zod when the variants share a literal discriminant field. Zod reads the discriminant first and validates only the matching branch — this produces a single, targeted error message instead of a noisy list of failures from every branch. In AJV's JSON Schema, oneOf validates against all branches and reports which ones failed — add additionalProperties: false to each branch to make errors more precise.
Custom Validation Rules and Error Message Formatting
Out-of-the-box validators cover common cases but every production application needs custom business rules: date range consistency, cross-field dependencies, format constraints, and internationalized error messages for global users. All four libraries support custom validation; the mechanisms differ.
// ── Zod: custom rules with .refine() and .superRefine() ──────────
import { z } from 'zod'
const DateRangeSchema = z.object({
startDate: z.string().date(), // "YYYY-MM-DD"
endDate: z.string().date(),
}).refine(
data => data.startDate <= data.endDate,
{ message: 'End date must be after start date', path: ['endDate'] }
)
const PasswordSchema = z.object({
password: z.string().min(8),
confirm: z.string(),
}).superRefine((data, ctx) => {
if (data.password !== data.confirm) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['confirm'],
message: 'Passwords do not match',
})
}
if (!/[A-Z]/.test(data.password)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['password'],
message: 'Password must contain at least one uppercase letter',
})
}
})
// ── Zod: internationalized error messages with error map ──────────
import { z, ZodErrorMap, ZodIssueCode } from 'zod'
type Locale = 'en' | 'fr' | 'de' | 'ja'
const messages: Record<Locale, Record<string, string>> = {
en: {
[ZodIssueCode.invalid_type]: 'Expected {expected}, received {received}',
[ZodIssueCode.too_small]: 'Value is too small (minimum: {minimum})',
[ZodIssueCode.too_big]: 'Value is too large (maximum: {maximum})',
[ZodIssueCode.invalid_string]: 'Invalid format: {validation}',
custom: 'Validation failed',
},
fr: {
[ZodIssueCode.invalid_type]: 'Attendu {expected}, reçu {received}',
[ZodIssueCode.too_small]: 'Valeur trop petite (minimum : {minimum})',
[ZodIssueCode.too_big]: 'Valeur trop grande (maximum : {maximum})',
[ZodIssueCode.invalid_string]: 'Format invalide : {validation}',
custom: 'Validation échouée',
},
de: {
[ZodIssueCode.invalid_type]: 'Erwartet {expected}, erhalten {received}',
[ZodIssueCode.too_small]: 'Wert zu klein (Minimum: {minimum})',
[ZodIssueCode.too_big]: 'Wert zu groß (Maximum: {maximum})',
[ZodIssueCode.invalid_string]: 'Ungültiges Format: {validation}',
custom: 'Validierung fehlgeschlagen',
},
ja: {
[ZodIssueCode.invalid_type]: '{expected}が必要です({received}を受信)',
[ZodIssueCode.too_small]: '値が小さすぎます(最小値:{minimum})',
[ZodIssueCode.too_big]: '値が大きすぎます(最大値:{maximum})',
[ZodIssueCode.invalid_string]: '無効な形式:{validation}',
custom: '検証に失敗しました',
},
}
function buildErrorMap(locale: Locale): ZodErrorMap {
return (issue, ctx) => {
const template = messages[locale][issue.code] ?? ctx.defaultError
// Simple token replacement — use a proper i18n library for production
const message = template
.replace('{expected}', 'expected' in issue ? String(issue.expected) : '')
.replace('{received}', 'received' in issue ? String(issue.received) : '')
.replace('{minimum}', 'minimum' in issue ? String(issue.minimum) : '')
.replace('{maximum}', 'maximum' in issue ? String(issue.maximum) : '')
.replace('{validation}','validation' in issue ? String(issue.validation) : '')
return { message }
}
}
// Set globally or per-parse
z.setErrorMap(buildErrorMap('fr'))
// Per-parse locale (recommended for multi-locale APIs)
const result = UserSchema.safeParse(data, { errorMap: buildErrorMap(userLocale) })
// ── AJV: custom keyword + error messages ─────────────────────────
ajv.addKeyword({
keyword: 'isAfterToday',
type: 'string',
schemaType: 'boolean',
validate: function validateAfterToday(schema: boolean, data: string): boolean {
if (!schema) return true
return new Date(data) > new Date()
},
errors: false,
})
const EventSchema = {
type: 'object',
properties: {
startDate: {
type: 'string',
format: 'date',
isAfterToday: true,
errorMessage: { isAfterToday: 'Start date must be in the future' },
},
},
required: ['startDate'],
}For production internationalization, integrate Zod's error map with a real i18n library (next-intl, i18next, react-i18next). Pass errorMap per request using the user's locale from the Accept-Language header — do not call z.setErrorMap() globally in a server environment since it is a shared mutable singleton that would affect all concurrent requests. Use per-parse error maps (second argument to safeParse) for thread-safe locale handling in concurrent server environments. See JSON error handling for complete error response patterns.
Database-Level JSON Validation: PostgreSQL CHECK and MongoDB Schema
Database-level validation is the last line of defense. It catches data that bypasses your application layer — direct SQL inserts, migration scripts, bulk imports, or a bug in application code. For PostgreSQL, jsonb columns validate JSON syntax on insert, and CHECK constraints enforce structure. MongoDB supports JSON Schema validation natively via collection validators.
-- ── PostgreSQL: jsonb column with CHECK constraints ─────────────
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
payload JSONB NOT NULL, -- jsonb validates JSON syntax
created_at TIMESTAMPTZ DEFAULT now(),
-- Enforce required fields at database level
CONSTRAINT orders_has_order_id
CHECK (payload ? 'orderId'), -- ? checks key existence
CONSTRAINT orders_has_amount
CHECK (payload ? 'amount' AND jsonb_typeof(payload->'amount') = 'number'),
-- Prevent negative amounts
CONSTRAINT orders_positive_amount
CHECK ((payload->>'amount')::numeric > 0),
-- Enforce status is a valid enum value
CONSTRAINT orders_valid_status
CHECK (
NOT payload ? 'status' OR
payload->>'status' IN ('pending', 'paid', 'cancelled', 'refunded')
)
);
-- Create GIN index for fast JSON key/value queries
CREATE INDEX orders_payload_gin ON orders USING GIN (payload);
-- Query with JSON operators
SELECT * FROM orders WHERE payload->>'status' = 'paid';
SELECT * FROM orders WHERE (payload->>'amount')::numeric > 100;
SELECT * FROM orders WHERE payload @> '{"status":"pending"}'; -- containment
-- ── pg_jsonschema: full JSON Schema validation in PostgreSQL ──────
-- Available on Supabase, Neon, and installable on self-hosted Postgres
CREATE EXTENSION IF NOT EXISTS pg_jsonschema;
ALTER TABLE orders
ADD CONSTRAINT orders_payload_schema
CHECK (
validate_json_schema(
'{
"type": "object",
"required": ["orderId", "amount", "items"],
"properties": {
"orderId": { "type": "string", "format": "uuid" },
"amount": { "type": "number", "exclusiveMinimum": 0 },
"items": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["sku", "qty"],
"properties": {
"sku": { "type": "string" },
"qty": { "type": "integer", "minimum": 1 }
},
"additionalProperties": false
}
}
},
"additionalProperties": false
}',
payload
)
);// ── MongoDB: collection-level JSON Schema validation ─────────────
db.createCollection('orders', {
validator: {
$jsonSchema: {
bsonType: 'object',
required: ['orderId', 'amount', 'status', 'items'],
additionalProperties: false,
properties: {
_id: { bsonType: 'objectId' },
orderId: { bsonType: 'string', pattern: '^ORD-[0-9]{6}$' },
amount: { bsonType: 'double', minimum: 0.01 },
status: { enum: ['pending', 'paid', 'cancelled', 'refunded'] },
items: {
bsonType: 'array',
minItems: 1,
items: {
bsonType: 'object',
required: ['sku', 'qty', 'price'],
properties: {
sku: { bsonType: 'string' },
qty: { bsonType: 'int', minimum: 1 },
price: { bsonType: 'double', minimum: 0 },
},
},
},
createdAt: { bsonType: 'date' },
},
},
},
validationLevel: 'strict', // reject non-conforming inserts AND updates
validationAction: 'error', // error = reject; warn = log only
})
// Add validation to existing collection
db.runCommand({
collMod: 'orders',
validator: { $jsonSchema: { /* schema */ } },
validationLevel: 'moderate', // only validate documents that already match
})PostgreSQL's jsonb_typeof() function returns the JSON type of a value as a string ('object', 'array', 'string', 'number', 'boolean', 'null') — use it in CHECK constraints to enforce field types within JSONB columns. The pg_jsonschema extension supports full Draft 7 JSON Schema validation inside constraints, which is the cleanest approach for complex schemas. MongoDB's validationLevel: 'moderate' only validates documents that already passed the schema — use it during migrations when you cannot update all existing documents immediately. For more on JSON in PostgreSQL, see our JSON Schema patterns guide.
Key Terms
- Schema validation
- The process of checking a JSON object against a formal schema — a description of the expected types, required fields, value constraints, and structure. Returns pass/fail plus a list of constraint violations. Schema validation is distinct from syntax validation (which only checks whether the JSON is parseable). Libraries like AJV and Zod perform schema validation;
JSON.parse()performs only syntax validation. Validation should happen at every trust boundary in a system. - AJV
- Another JSON Validator — the most widely used JSON Schema validator for JavaScript. AJV compiles a JSON Schema document (a plain JSON object following the JSON Schema specification) into an optimized JavaScript function at startup. The compiled function validates objects at 10 million per second with no per-call schema traversal. AJV supports JSON Schema drafts 4, 6, 7, and 2020-12, and offers plugins for custom formats (
ajv-formats), custom error messages (ajv-errors), and additional keywords (ajv-keywords). Install withnpm install ajv. - Zod
- A TypeScript-first schema declaration and validation library where schemas are defined as TypeScript expressions (
z.object(),z.string(),z.number()). Zod automatically infers static TypeScript types from schema definitions viaz.infer<typeof Schema>, eliminating the need for separate type declarations. Zod validates at roughly 300K ops/sec for typical schemas — adequate for per-request API validation. Thez.safeParse()method never throws and returns a typed discriminated union, making it safe at all boundaries. Install withnpm install zod. - safeParse()
- A Zod method that validates data without throwing on failure. Returns
{ success: true, data: T }when valid or{ success: false, error: ZodError }when invalid. The discriminated union return type allows TypeScript to narrow the type in each branch:if (result.success) { /* result.data is typed as T */ }. Contrast withz.parse()which throws aZodErroron failure — appropriate only for code paths where you control the input. Always usesafeParse()at API boundaries. - Discriminated union
- A union type where each variant is identified by a shared literal field (the discriminant), typically named
type,kind, orevent. When the discriminant istype: "circle", only the circle schema is applied. Zod supports this withz.discriminatedUnion("type", [...])— it reads the discriminant field first and validates only the matching branch, producing targeted errors and faster validation thanz.union()which tries all branches. In JSON Schema, discriminated unions are expressed withoneOfandconstdiscriminants. - additionalProperties
- A JSON Schema keyword that controls whether properties not listed in
propertiesare allowed. SettingadditionalProperties: falserejects any extra key — this prevents injection of unexpected fields that could cause security issues or corrupt downstream processing. OmittingadditionalProperties(the default) allows any extra keys silently. In Zod,z.object({...})strips unknown keys by default; use.strict()to reject them instead:z.object({...}).strict(). - Trust boundary
- A point in a system where data crosses from a less-trusted context into a more-trusted one. Common trust boundaries: the network edge (HTTP request body from clients), inter-service API calls (data from another microservice), message queue consumers (events from a broker), file imports (CSV or JSON uploads), and database reads (data written by an older version of the application). Validation at a trust boundary ensures that only data conforming to the expected schema enters the system, catching errors before they propagate to business logic.
FAQ
What is the best JSON validation library for JavaScript?
There is no single best library — the right choice depends on your use case. AJV is best for high-throughput server-side validation where 10M ops/sec and OpenAPI compatibility matter. Zod is best for TypeScript-first projects where type inference from schema eliminates duplicate type declarations. Yup is best for browser forms requiring async validation (checking username availability against a database). Valibot is best when bundle size is critical — at 1.9 KB gzipped, it is the right choice for edge runtimes with tight size limits. For most TypeScript fullstack projects, Zod is the pragmatic default. For performance-sensitive batch processing or cross-language schema sharing, use AJV with JSON Schema.
What is the difference between AJV and Zod for JSON validation?
AJV and Zod take fundamentally different approaches. AJV accepts JSON Schema — a standard declarative format written as a plain JSON object that compiles to an optimized JavaScript function validating at 10M ops/sec. Schemas are portable across languages and integrate with OpenAPI specs but do not generate TypeScript types. Zod defines schemas as TypeScript code using a fluent API and automatically infers TypeScript types via z.infer<typeof schema> — your runtime validator and compile-time type stay in sync. Zod validates at roughly 300K ops/sec — adequate for per-request API validation. Use AJV when you need speed, OpenAPI compatibility, or cross-language schema sharing. Use Zod when TypeScript type inference and developer ergonomics are the priority.
How do I validate required fields and types in JSON?
In JSON Schema (AJV), list required fields in a top-level "required" array and define types in "properties": { "type": "object", "properties": { "name": { "type": "string" }, "age": { "type": "integer", "minimum": 0 } }, "required": ["name", "age"], "additionalProperties": false }. The "additionalProperties": false clause rejects any key not listed in properties. In Zod, all fields in z.object() are required by default — append .optional() to mark a field optional: z.object({ name: z.string(), age: z.number().int().nonnegative() }). TypeScript type is inferred automatically. For nested objects, nest z.object() calls or JSON Schema property objects — both approaches work recursively at any depth.
How do I add custom error messages to JSON validation?
In Zod, pass a string or object as the second argument to any validator: z.string({ required_error: "Email is required" }).email({ message: "Must be a valid email" }). For cross-field rules, use .refine() with a message and path option to target a specific field. For internationalization, pass a locale-specific errorMap as the second argument to safeParse() — do not use z.setErrorMap() globally on servers since it mutates shared state. In AJV, install ajv-errors and add an "errorMessage" keyword to your schema keyed by constraint name. For i18n, post-process validate.errors and replace each message field using your translation lookup table keyed on error.keyword.
How do I validate a JSON union type or discriminated union?
A discriminated union uses a shared literal field to identify the schema variant. In Zod, use z.discriminatedUnion("type", [z.object({ type: z.literal("circle"), radius: z.number() }), z.object({ type: z.literal("rect"), width: z.number(), height: z.number() })]). Zod reads the discriminant first and validates only the matching branch — this produces targeted errors and is faster than z.union() which tries all branches. In JSON Schema (AJV), use oneOf with a const discriminant in each branch and add "additionalProperties": false per branch to reject unknown fields. Discriminated unions are common in event-driven systems where a shared event or type field identifies the payload shape.
What is Valibot and how does it differ from Zod?
Valibot is a TypeScript validation library with a modular, tree-shakeable architecture where every validator is a separate import — only the functions you use end up in the bundle. The result is 1.9 KB gzipped compared to Zod's 13 KB. The API is similar: v.object({ name: v.string(), age: v.number() }) defines a schema and v.safeParse(schema, data) validates. Valibot 1.x uses a pipeline model for composition: v.pipe(v.string(), v.email(), v.maxLength(255)). TypeScript types are inferred via v.InferOutput<typeof schema>. The key trade-off is ecosystem: Zod has broader first-party support in tRPC, React Hook Form, Prisma, and Drizzle. Choose Valibot for edge runtimes with strict size limits; choose Zod for broadest ecosystem compatibility.
Where should I validate JSON in a full-stack application?
Validate at every trust boundary — the points where data crosses from a less-trusted context into your system. Three primary boundaries: (1) Client form — validate before submission with Zod or Yup and React Hook Form to give users immediate feedback and reduce unnecessary network requests. Client validation is UX-only — it can be bypassed. (2) API server endpoint — validate every incoming request body, query parameter, and route parameter before processing. Use middleware that runs z.safeParse() or AJV and returns 400 with structured errors on failure. Never trust client-validated data at the server. (3) Database layer — use CHECK constraints (PostgreSQL) or collection validators (MongoDB) to enforce data integrity at the storage layer. This catches data that bypasses the application layer entirely.
How do I validate JSON in a database with PostgreSQL?
PostgreSQL provides two mechanisms. First, use jsonb column type (not json) — it validates JSON syntax on insert and supports GIN indexing for fast JSON queries. Second, add CHECK constraints using PostgreSQL's JSON operators: payload ? 'orderId' checks key existence; jsonb_typeof(payload->'amount') = 'number' checks the JSON type of a value; (payload->>'amount')::numeric > 0 validates the numeric value. For full JSON Schema validation, install the pg_jsonschema extension (available on Supabase and Neon) and use validate_json_schema(schema_json, payload) in a CHECK constraint — this supports Draft 7 JSON Schema inside PostgreSQL constraints. Database validation is the last line of defense for data integrity.
Further reading and primary sources
- Zod Documentation — Official Zod docs covering schema definition, safeParse, discriminated unions, error maps, and TypeScript integration
- AJV — Another JSON Validator — Official AJV docs: compilation, custom keywords, ajv-formats, ajv-errors, and JSON Schema Draft 2020-12 support
- Valibot Documentation — Official Valibot docs covering pipeline-based validation, modular imports, and TypeScript type inference
- Yup on GitHub — Yup schema validation library — async validators, abortEarly option, and Formik/React Hook Form integration
- pg_jsonschema Extension — PostgreSQL extension for JSON Schema validation inside CHECK constraints — available on Supabase and self-hosted Postgres