Zod logo

zod

Schema validation with TypeScript type inference

$ npx docs2skills add zod-validation
SKILL.md

Zod

TypeScript-first schema validation with static type inference

What this skill does

Zod is a TypeScript-first validation library that lets you define schemas to validate data at runtime while providing complete TypeScript type safety. Unlike other validation libraries that require separate type definitions, Zod infers TypeScript types directly from your schema definitions, eliminating the need to maintain types and validators separately.

Zod is essential for validating untrusted data from APIs, user inputs, configuration files, or any external source. It provides a fluent, immutable API where each method returns a new schema instance, making it safe to compose and reuse schemas. With zero dependencies and a tiny 2kb core bundle, Zod works in Node.js and all modern browsers.

The library excels at catching type mismatches at runtime that TypeScript can't detect at compile time, such as malformed API responses or invalid user inputs, while maintaining full type safety throughout your application.

Prerequisites

  • TypeScript v5.5 or later (older versions may work but unsupported)
  • "strict": true in tsconfig.json (required)
  • Node.js or modern browser environment

Quick start

npm install zod
import { z } from "zod";

// Define a schema
const User = z.object({
  name: z.string(),
  email: z.string().email(),
  age: z.number().min(18),
});

// Parse and validate data
const userData = { name: "John", email: "john@example.com", age: 25 };
const result = User.parse(userData); // Throws on invalid data
// result is now typed as { name: string; email: string; age: number; }

// Safe parsing (doesn't throw)
const safeResult = User.safeParse(userData);
if (safeResult.success) {
  console.log(safeResult.data.name); // Type-safe access
} else {
  console.log(safeResult.error); // ZodError with details
}

Core concepts

Schemas are the fundamental building blocks - they define the expected structure and validation rules for data. Every Zod type is a schema with methods for parsing, validation, and transformation.

Immutable API means every method returns a new schema instance rather than modifying the existing one. This makes schemas safely reusable and composable across your application.

Type inference automatically generates TypeScript types from your schemas using z.infer<typeof Schema>. The schema definition is the single source of truth for both runtime validation and compile-time types.

Parsing vs Safe Parsing: parse() throws ZodError on invalid data, while safeParse() returns a result object with success/error properties. Choose based on whether you want to handle errors with try/catch or explicit checking.

Key API surface

// Basic types
z.string()           // string validation
z.number()           // number validation  
z.boolean()          // boolean validation
z.date()             // Date object validation
z.array(schema)      // array validation
z.object({ ... })    // object validation
z.union([s1, s2])    // union types (s1 | s2)
z.enum(["a", "b"])   // enum validation

// Parsing methods
schema.parse(data)         // Parse, throw on error
schema.safeParse(data)     // Parse, return result object
schema.parseAsync(data)    // Async parsing with promises

// Schema methods
schema.optional()          // Make schema optional
schema.nullable()          // Allow null values
schema.default(value)      // Provide default value
schema.transform(fn)       // Transform parsed data
schema.refine(fn, msg)     // Custom validation logic

// Type inference
type User = z.infer<typeof UserSchema>

Common patterns

API response validation:

const ApiResponse = z.object({
  data: z.array(z.object({
    id: z.string(),
    name: z.string(),
  })),
  pagination: z.object({
    page: z.number(),
    total: z.number(),
  }),
});

const response = await fetch('/api/users');
const validated = ApiResponse.parse(await response.json());

Form validation with error handling:

const LoginForm = z.object({
  email: z.string().email("Invalid email format"),
  password: z.string().min(8, "Password must be at least 8 characters"),
});

const result = LoginForm.safeParse(formData);
if (!result.success) {
  const errors = result.error.flatten();
  // Handle field-specific errors
}

Schema composition and reuse:

const BaseUser = z.object({
  name: z.string(),
  email: z.string().email(),
});

const AdminUser = BaseUser.extend({
  permissions: z.array(z.string()),
  isAdmin: z.literal(true),
});

const PublicUser = BaseUser.pick({ name: true }); // Only name field

Environment variable validation:

const envSchema = z.object({
  NODE_ENV: z.enum(["development", "production", "test"]),
  DATABASE_URL: z.string().url(),
  PORT: z.string().transform(val => parseInt(val, 10)),
});

const env = envSchema.parse(process.env);

Custom validation with refine:

const PasswordSchema = z.string()
  .min(8)
  .refine(val => /[A-Z]/.test(val), "Must contain uppercase letter")
  .refine(val => /[0-9]/.test(val), "Must contain number");

Configuration

TypeScript configuration (required):

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,  // Required for Zod to work properly
    "strictNullChecks": true
  }
}

Error customization:

const schema = z.string({
  required_error: "Field is required",
  invalid_type_error: "Must be a string"
});

// Global error map
z.setErrorMap((issue, ctx) => {
  return { message: "Custom error message" };
});

Best practices

  • Always use safeParse() for user inputs to avoid throwing errors in normal flow
  • Define schemas at module level and reuse them - they're immutable and safe to share
  • Use transform() for data normalization (trimming strings, parsing dates) not validation
  • Prefer refine() over transform() for validation logic that might fail
  • Use z.infer<typeof Schema> instead of manually defining TypeScript types
  • Create base schemas and extend them rather than duplicating validation logic
  • Use pick(), omit(), and partial() to derive related schemas
  • Validate at system boundaries (API endpoints, file parsing, external data)
  • Use descriptive error messages for user-facing validation
  • Consider parseAsync() for schemas with async refinements or transforms

Gotchas and common mistakes

Schema reuse and mutation: Schemas are immutable. schema.optional() returns a NEW schema - it doesn't modify the original. Always assign the result: const optionalSchema = schema.optional().

Strict object parsing: By default, z.object() strips unknown properties. Use .passthrough() to keep them or .strict() to throw errors on unknown keys.

Number parsing from strings: z.number() only accepts actual numbers. For string-to-number conversion (like form inputs), use z.coerce.number() or z.string().transform(val => parseInt(val)).

Date validation confusion: z.date() only accepts Date objects, not date strings. For ISO date strings, use z.string().datetime() or z.coerce.date().

Union order matters: In z.union([schema1, schema2]), Zod tries schemas in order. Put more specific schemas first to avoid unexpected matches.

Error handling with parse(): schema.parse() throws ZodError on invalid data. If you don't want exceptions in normal flow, use safeParse() instead.

Transform vs refine: Use transform() for data conversion that always succeeds. Use refine() for validation that can fail. Don't use transform() for validation logic.

Async validation gotchas: Only use parseAsync() if you have async refine() or transform() functions. Regular sync schemas should use parse() or safeParse().

Type inference limitations: z.infer doesn't work with dynamically created schemas. Define schemas as const at module level for proper type inference.

Default values timing: .default() only applies when the field is undefined, not when it's null or an empty string. Use .nullish().default() for broader default behavior.

Recursive schema issues: Self-referencing schemas need z.lazy() to avoid infinite recursion: const Category = z.object({ subcategories: z.lazy(() => z.array(Category)) }).

Performance with large schemas: Complex nested schemas with many refinements can be slow. Consider caching parsed results or using simpler validation for performance-critical paths.

Zod — docs2skills