Lab 07: Runtime Validation Advanced

Time: 40 minutes | Level: Advanced | Docker: docker run -it --rm node:20-alpine sh

Master Zod's advanced features: discriminated unions, lazy recursive schemas, transforms, branded types, and type-safe error formatting.


Step 1: Environment Setup

docker run -it --rm node:20-alpine sh
npm install -g typescript ts-node
mkdir lab07 && cd lab07
npm init -y
npm install zod
echo '{"compilerOptions":{"module":"commonjs","target":"es2020","strict":true,"esModuleInterop":true}}' > tsconfig.json

💡 Zod schemas are both runtime validators AND TypeScript type definitions. z.infer<typeof Schema> extracts the TypeScript type — no duplication needed.


Step 2: Discriminated Unions

Parse polymorphic payloads with full type safety:

// shapes.ts
import { z } from 'zod';

// z.discriminatedUnion is faster than z.union — checks the discriminator key first
const ShapeSchema = z.discriminatedUnion('kind', [
  z.object({
    kind: z.literal('circle'),
    radius: z.number().positive(),
  }),
  z.object({
    kind: z.literal('rectangle'),
    width: z.number().positive(),
    height: z.number().positive(),
  }),
  z.object({
    kind: z.literal('triangle'),
    base: z.number().positive(),
    height: z.number().positive(),
  }),
]);

type Shape = z.infer<typeof ShapeSchema>;

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'rectangle':
      return shape.width * shape.height;
    case 'triangle':
      return 0.5 * shape.base * shape.height;
  }
}

const shapes = [
  { kind: 'circle', radius: 5 },
  { kind: 'rectangle', width: 4, height: 6 },
  { kind: 'triangle', base: 3, height: 8 },
];

shapes.forEach(raw => {
  const shape = ShapeSchema.parse(raw);
  console.log(`${shape.kind}: area = ${area(shape).toFixed(2)}`);
});

Step 3: Lazy Recursive Schemas

Handle tree-structured or recursive data:

💡 z.lazy() takes a function that returns the schema, breaking the circular reference at declaration time.


Step 4: Transform and Preprocess

Shape data as it's validated:


Step 5: Branded Types for Nominal Typing

Create distinct types from identical primitives:

💡 Brands are zero-cost at runtime — they're erased after parsing. The brand only exists in TypeScript's type system.


Step 6: ZodError Formatting

Parse and display validation errors clearly:


Step 7: Complex Schema Composition

Build a full API request validator:


Step 8: Capstone — API Validation Middleware

Run:

📸 Verified Output:


Summary

Feature
API
Use Case

Polymorphic types

z.discriminatedUnion('key', [...])

Parse variant objects

Recursive schemas

z.lazy(() => schema)

Trees, nested data

Type coercion

z.preprocess(fn, schema)

String → number from URL params

Data transformation

.transform(fn)

Normalize after parse

Nominal typing

.brand<'TypeName'>()

Prevent ID mixing

Type extraction

z.infer<typeof Schema>

Zero-duplication types

Error formatting

.error.flatten()

Clean field → error map

Last updated