Episode 4 — Generative AI Engineering / 4.6 — Schema Validation with Zod
4.6 — Schema Validation with Zod: Quick Revision
Compact cheat sheet. Print-friendly.
How to use this material (instructions)
- Skim before labs or interviews.
- Drill gaps — reopen
README.md→4.6.a…4.6.e. - Practice —
4.6-Exercise-Questions.md. - Polish answers —
4.6-Interview-Questions.md.
Core principle
NEVER trust AI output without validation.
AI response = string → JSON.parse() → Zod safeParse() → typed data or structured error
Core vocabulary
| Term | One-liner |
|---|---|
| Zod | TypeScript-first runtime schema validation library (~13KB gzipped, zero deps) |
| Schema | A Zod object describing the expected shape, types, and constraints of data |
| z.infer | Derives TypeScript type from a Zod schema — single source of truth |
| parse() | Validates data, returns typed result or throws ZodError |
| safeParse() | Validates data, returns {success, data/error} — never throws |
| ZodError | Structured error with .issues[] containing path, code, message |
| z.coerce | Converts input type before validation (e.g., string "42" → number 42) |
| z.preprocess | Runs a custom function before schema validation |
| .transform() | Converts the validated value to a different shape/type |
| .refine() | Adds custom validation logic (can access the full parsed value) |
Installation
npm install zod
import { z } from 'zod';
Basic schemas
z.string() // string
z.number() // number
z.boolean() // boolean
z.null() // null
z.undefined() // undefined
z.literal('value') // exact value
z.array(z.string()) // string[]
z.object({ k: z.string() }) // { k: string }
z.enum(['a', 'b', 'c']) // 'a' | 'b' | 'c'
z.union([z.string(), z.number()]) // string | number
z.discriminatedUnion('type', [...]) // union with discriminator field
String refinements
z.string().min(1) // non-empty
z.string().max(100) // max length
z.string().email() // email format
z.string().url() // URL format
z.string().uuid() // UUID format
z.string().datetime() // ISO datetime
z.string().regex(/pattern/) // regex match
z.string().trim() // trim whitespace (transform)
z.string().toLowerCase() // to lowercase (transform)
Number refinements
z.number().min(0) // >= 0
z.number().max(100) // <= 100
z.number().int() // integer only
z.number().positive() // > 0
z.number().nonnegative() // >= 0
z.number().finite() // no Infinity/NaN
z.number().multipleOf(5) // divisible by 5
Optional / nullable / defaults
z.string().optional() // string | undefined
z.string().nullable() // string | null
z.string().nullish() // string | null | undefined
z.string().default('hi') // fills in 'hi' if undefined
z.object({...}).default({}) // fills in {} if undefined
Type inference
const Schema = z.object({ name: z.string(), age: z.number() });
type MyType = z.infer<typeof Schema>;
// { name: string; age: number }
parse() vs safeParse()
// parse() — throws on failure
try {
const data = Schema.parse(input); // typed result
} catch (e) {
if (e instanceof z.ZodError) { /* handle */ }
}
// safeParse() — never throws (PREFERRED for AI)
const result = Schema.safeParse(input);
if (result.success) {
result.data; // typed result
} else {
result.error.issues; // structured errors
}
ZodError quick reference
error.issues // Array of { path, code, message, expected?, received? }
error.format() // Nested object grouped by path
error.flatten() // { formErrors: [], fieldErrors: { field: [msgs] } }
AI validation pipeline
Step 1: Get raw string from API
Step 2: JSON.parse() — handle SyntaxError
Step 3: schema.safeParse() — handle validation errors
Step 4: Use typed data or handle error
function validateAI<T>(raw: string, schema: z.ZodSchema<T>) {
let parsed: unknown;
try { parsed = JSON.parse(raw); }
catch { throw new Error('Invalid JSON'); }
return schema.parse(parsed);
}
JSON extraction (most common failure)
function extractJSON(text: string): unknown {
try { return JSON.parse(text); } catch {}
// Remove markdown fences
const fence = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
if (fence) try { return JSON.parse(fence[1].trim()); } catch {}
// Find first {...} block
const obj = text.match(/(\{[\s\S]*\})/);
if (obj) try { return JSON.parse(obj[1]); } catch {}
throw new Error('No JSON found');
}
Type coercion patterns
// String → Number (safe)
z.preprocess(v => typeof v === 'string' ? Number(v) : v, z.number())
// Percentage → Decimal (95 → 0.95)
z.number().transform(n => n > 1 ? n / 100 : n).pipe(z.number().min(0).max(1))
// String → Boolean (safe — z.coerce.boolean() is DANGEROUS)
z.union([
z.boolean(),
z.literal('true').transform(() => true),
z.literal('false').transform(() => false),
])
// String or Array → Array
z.union([
z.array(z.string()),
z.string().transform(s => s.split(',').map(t => t.trim())),
])
z.coerce gotchas
z.coerce.number() on '' → 0 (probably wrong)
z.coerce.boolean() on 'false' → true (definitely wrong)
z.coerce.number() on 'abc' → NaN (fails downstream)
Use z.preprocess() or .transform() for safe coercion.
Retry with error feedback
Attempt 1: Call AI → validate → FAIL
→ Format errors: "Field 'confidence': must be <= 1"
→ Add as user message to conversation
Attempt 2: Call AI (with error context) → validate → SUCCESS (98% of the time)
Attempt 3: (rarely needed)
// Feed errors back to model
const errorMsg = error.issues
.map(i => `- "${i.path.join('.')}": ${i.message}`)
.join('\n');
messages.push(
{ role: 'assistant', content: rawContent },
{ role: 'user', content: `Validation errors:\n${errorMsg}\nFix and respond with JSON.` },
);
Retry success rates
With error feedback:
Attempt 1: ~85-95% success
Attempt 2: ~95-99% success
Attempt 3: ~99%+ success
Without error feedback (blind retry):
Every attempt: ~85-95% (same odds each time)
Cost of retries
Retry cost grows NON-LINEARLY (conversation history grows):
Attempt 1: ~700 input tokens
Attempt 2: ~1,200 input tokens (1.7x)
Attempt 3: ~1,700 input tokens (2.4x)
3 retries ≈ 3.9x cost of single call (not 3x)
Graceful degradation ladder
Level 1: FULL SUCCESS → use data as-is
Level 2: PARTIAL → use valid fields, defaults for rest
Level 3: RECOVERABLE → coerce types, extract JSON
Level 4: RETRY-WORTHY → retry with error feedback
Level 5: HARD FAILURE → error response or fallback
Schema composition
schema.extend({ newField: z.string() }) // add fields
schema.merge(otherSchema) // combine two schemas
schema.pick({ field1: true }) // select fields
schema.omit({ secret: true }) // remove fields
schema.partial() // all fields optional
schema.required() // all fields required
callWithValidation() pattern
async function callWithValidation<T>(
schema: z.ZodSchema<T>,
systemPrompt: string,
userMessage: string,
options?: { maxRetries?: number; model?: string },
): Promise<{ success: boolean; data: T | null; attempts: number; errors: string[] }>
Key features:
- Generic over schema type (auto-inferred return type)
- JSON extraction built-in
- Error feedback on retry
- Token/cost tracking
- Lifecycle callbacks (onRetry, onSuccess)
Zod vs alternatives
| Library | Type Inference | Bundle | Best For |
|---|---|---|---|
| Zod | Excellent | ~13KB | AI validation, TypeScript projects |
| Joi | None | ~150KB | Legacy Node.js, Hapi ecosystem |
| Yup | Partial | ~40KB | React form validation |
| AJV | None | ~30KB | JSON Schema standard, raw speed |
What to monitor in production
1. Validation pass rate (target: >95%)
2. JSON parse success rate
3. Top failing fields (by count)
4. Retry rate and retry success rate
5. Cost of retries per day
6. Failure rate by model version
7. Failure rate trend over time
Alert when validation pass rate drops below 95% for 15 minutes.
Common gotchas
| Gotcha | Why |
|---|---|
| response_format: json_object does NOT mean schema compliance | Guarantees JSON syntax, not correct fields/types/values |
| z.coerce.boolean() on "false" returns true | Non-empty strings are truthy in JS |
| TypeScript types are erased at runtime | JSON.parse() returns any — no protection |
| Retry costs grow non-linearly | Each retry includes full conversation history |
| z.coerce.number() on "" returns 0 | Number("") === 0 in JavaScript |
| Missing field vs null field | .optional() handles missing; .nullable() handles null; .nullish() handles both |
End of 4.6 quick revision.