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)

  1. Skim before labs or interviews.
  2. Drill gaps — reopen README.md4.6.a4.6.e.
  3. Practice4.6-Exercise-Questions.md.
  4. Polish answers4.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

TermOne-liner
ZodTypeScript-first runtime schema validation library (~13KB gzipped, zero deps)
SchemaA Zod object describing the expected shape, types, and constraints of data
z.inferDerives 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
ZodErrorStructured error with .issues[] containing path, code, message
z.coerceConverts input type before validation (e.g., string "42" → number 42)
z.preprocessRuns 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

LibraryType InferenceBundleBest For
ZodExcellent~13KBAI validation, TypeScript projects
JoiNone~150KBLegacy Node.js, Hapi ecosystem
YupPartial~40KBReact form validation
AJVNone~30KBJSON 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

GotchaWhy
response_format: json_object does NOT mean schema complianceGuarantees JSON syntax, not correct fields/types/values
z.coerce.boolean() on "false" returns trueNon-empty strings are truthy in JS
TypeScript types are erased at runtimeJSON.parse() returns any — no protection
Retry costs grow non-linearlyEach retry includes full conversation history
z.coerce.number() on "" returns 0Number("") === 0 in JavaScript
Missing field vs null field.optional() handles missing; .nullable() handles null; .nullish() handles both

End of 4.6 quick revision.