D
Dev SOPKnowledge Base
Search
← All topics

TypeScript and Zod: Type Safety, Boundary Validation, and Schema Patterns

TypeScript production patterns with Zod — boundary validation at API inputs/outputs, schema composition, discriminated unions, branded types, transform pipelines, error formatting, and inference patterns for full-stack type safety.

typescriptzodvalidationtypesschemaboundary
Agent trigger phrases: Zod validation · Zod schema · TypeScript Zod · boundary validation · API input validation · Zod parse · Zod safeParse · TypeScript types

Overview

Validate at every trust boundary — API inputs, environment variables, external API responses, form data. Zod provides runtime validation with TypeScript type inference. Never trust data from outside your process boundary.

Core Patterns

Basic Schema and Inference

import { z } from "zod";

const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  name: z.string().min(1).max(100),
  role: z.enum(["admin", "user", "viewer"]),
  createdAt: z.string().datetime(),
  metadata: z.record(z.string(), z.unknown()).optional(),
});

// Infer TypeScript type from schema
type User = z.infer<typeof UserSchema>;

// Parse (throws on failure)
const user = UserSchema.parse(data);

// Safe parse (returns result object)
const result = UserSchema.safeParse(data);
if (!result.success) {
  console.error(result.error.flatten());
} else {
  const user = result.data;
}

Schema Composition

// Base schemas
const IdSchema = z.string().uuid();
const TimestampSchema = z.string().datetime();

const BaseEntitySchema = z.object({
  id: IdSchema,
  createdAt: TimestampSchema,
  updatedAt: TimestampSchema,
});

// Extend base
const ProjectSchema = BaseEntitySchema.extend({
  name: z.string().min(1).max(100),
  status: z.enum(["active", "archived", "deleted"]),
  userId: IdSchema,
});

// Create insert schema (omit server-generated fields)
const CreateProjectSchema = ProjectSchema.omit({ id: true, createdAt: true, updatedAt: true });
const UpdateProjectSchema = ProjectSchema.partial().required({ id: true });

type Project = z.infer<typeof ProjectSchema>;
type CreateProject = z.infer<typeof CreateProjectSchema>;
type UpdateProject = z.infer<typeof UpdateProjectSchema>;

API Route Validation Pattern

// Always validate at the API boundary
export async function POST(request: NextRequest) {
  const body = await request.json().catch(() => null);

  if (!body) {
    return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
  }

  const result = CreateProjectSchema.safeParse(body);
  if (!result.success) {
    return NextResponse.json(
      {
        error: "Validation failed",
        details: result.error.flatten().fieldErrors,
      },
      { status: 422 }
    );
  }

  const project = await createProject(result.data);
  return NextResponse.json({ project }, { status: 201 });
}

Environment Variable Validation

// src/lib/env.ts — run at startup, fails fast on missing vars
const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]),
  DATABASE_URL: z.string().url(),
  NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
  NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
  SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
  STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
  STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_"),
});

export const env = EnvSchema.parse(process.env);

Advanced Patterns

Discriminated Unions

const ApiResponseSchema = z.discriminatedUnion("status", [
  z.object({
    status: z.literal("success"),
    data: z.unknown(),
  }),
  z.object({
    status: z.literal("error"),
    code: z.number(),
    message: z.string(),
  }),
]);

type ApiResponse = z.infer<typeof ApiResponseSchema>;

function handleResponse(raw: unknown): void {
  const response = ApiResponseSchema.parse(raw);
  if (response.status === "success") {
    // TypeScript knows data is available here
    processData(response.data);
  } else {
    // TypeScript knows code and message are available here
    logError(response.code, response.message);
  }
}

Transform Pipeline

const StripeAmountSchema = z
  .number()
  .int()
  .positive()
  .transform((cents) => ({
    cents,
    dollars: cents / 100,
    formatted: new Intl.NumberFormat("en-US", {
      style: "currency",
      currency: "USD",
    }).format(cents / 100),
  }));

// StripeAmountSchema.parse(2900)
// → { cents: 2900, dollars: 29, formatted: "$29.00" }

Branded Types

// Prevent confusion between IDs of different entity types
const UserIdSchema = z.string().uuid().brand("UserId");
const ProjectIdSchema = z.string().uuid().brand("ProjectId");

type UserId = z.infer<typeof UserIdSchema>;
type ProjectId = z.infer<typeof ProjectIdSchema>;

// TypeScript error: can't pass ProjectId where UserId is expected
function getUser(id: UserId): Promise<User> { /* ... */ }
const projectId = ProjectIdSchema.parse("123e4567-...");
getUser(projectId);  // TS Error: Type 'ProjectId' is not assignable to 'UserId'

Coercion for Query Params

// URL params are always strings — coerce to the right type
const SearchParamsSchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  status: z.enum(["active", "archived"]).optional(),
  search: z.string().optional(),
});

export async function GET(request: NextRequest) {
  const params = Object.fromEntries(new URL(request.url).searchParams);
  const { page, limit, status, search } = SearchParamsSchema.parse(params);
  // page and limit are numbers, not strings
}

External API Response Validation

// Always validate responses from external APIs
const GHLContactSchema = z.object({
  id: z.string(),
  email: z.string().email().optional(),
  phone: z.string().optional(),
  firstName: z.string().optional(),
  lastName: z.string().optional(),
});

const GHLContactListSchema = z.object({
  contacts: z.array(GHLContactSchema),
  meta: z.object({
    total: z.number(),
    count: z.number(),
    currentPage: z.number(),
  }),
});

async function fetchContacts() {
  const raw = await ghlClient.get("/contacts");
  const validated = GHLContactListSchema.parse(raw);
  return validated;
}

Error Formatting

const result = UserSchema.safeParse(data);
if (!result.success) {
  // Field-level errors
  const fieldErrors = result.error.flatten().fieldErrors;
  // { email: ["Invalid email"], name: ["Too short"] }

  // Flat list of errors
  const issues = result.error.issues.map(issue => ({
    path: issue.path.join("."),
    message: issue.message,
  }));

  // First error only
  const firstError = result.error.issues[0]?.message;
}

TypeScript Strict Mode

Always enable in tsconfig.json:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "exactOptionalPropertyTypes": true
  }
}

strict: true enables: strictNullChecks, strictFunctionTypes, strictPropertyInitialization, noImplicitAny.