Runtime Type Validation with Zod — Schemas, Forms, and API Validation

Why Zod Is Needed

TypeScript types only exist at compile time. Once the build is complete, no type information remains in JavaScript. Data coming from external sources — user input, API responses, environment variables — must be validated at runtime.

Zod is a runtime validation library that automatically infers TypeScript types from schema definitions. A single schema handles both validation and type definition simultaneously.

Installation and Basic Schemas

npm install zod

Zod schemas are defined using chaining methods like z.object(), z.string(), and z.number().

import { z } from "zod";

// Basic schema definition
const UserSchema = z.object({
  name: z.string().min(2, "Name must be at least 2 characters"),
  email: z.string().email("Invalid email format"),
  age: z.number().int().min(0).max(150),
  role: z.enum(["admin", "user", "guest"]),
  bio: z.string().optional(), // Optional field
});

// Automatic TypeScript type inference from the schema
type User = z.infer<typeof UserSchema>;
// { name: string; email: string; age: number; role: "admin" | "user" | "guest"; bio?: string }

// Valid data — success
const validData = {
  name: "Alice",
  email: "alice@example.com",
  age: 30,
  role: "admin" as const,
};

const result = UserSchema.safeParse(validData);
if (result.success) {
  console.log("Validation passed:", result.data);
  // Validation passed: { name: "Alice", email: "alice@example.com", age: 30, role: "admin" }
} else {
  console.log("Validation failed:", result.error.issues);
}

// Invalid data — failure
const invalidData = {
  name: "A",           // Less than 2 characters
  email: "not-email",  // Not email format
  age: -5,             // Negative
  role: "superuser",   // Not in enum
};

const failResult = UserSchema.safeParse(invalidData);
if (!failResult.success) {
  failResult.error.issues.forEach((issue) => {
    console.log(`${issue.path.join(".")}: ${issue.message}`);
  });
  // name: Name must be at least 2 characters
  // email: Invalid email format
  // age: Number must be greater than or equal to 0
  // role: Invalid enum value. Expected 'admin' | 'user' | 'guest', received 'superuser'
}

safeParse returns a result object without throwing exceptions. parse throws a ZodError on failure. safeParse is recommended for external input.

Advanced Schema Patterns

Zod provides various advanced features including transforms, refinements, and composition.

import { z } from "zod";

// transform: convert values after validation
const TrimmedString = z.string().trim().toLowerCase();
console.log(TrimmedString.parse("  Hello World  ")); // "hello world"

// coerce: force-convert input before validation
const CoercedNumber = z.coerce.number();
console.log(CoercedNumber.parse("42")); // 42 (string -> number)

const CoercedDate = z.coerce.date();
console.log(CoercedDate.parse("2026-01-15")); // Date object

// refine: custom validation logic
const PasswordSchema = z
  .string()
  .min(8, "Password must be at least 8 characters")
  .refine((val) => /[A-Z]/.test(val), {
    message: "Must contain at least 1 uppercase letter",
  })
  .refine((val) => /[0-9]/.test(val), {
    message: "Must contain at least 1 number",
  })
  .refine((val) => /[!@#$%^&*]/.test(val), {
    message: "Must contain at least 1 special character",
  });

// superRefine: cross-field validation
const SignupSchema = z
  .object({
    password: z.string().min(8),
    confirmPassword: z.string(),
  })
  .superRefine((data, ctx) => {
    if (data.password !== data.confirmPassword) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "Passwords do not match",
        path: ["confirmPassword"],
      });
    }
  });

// discriminatedUnion: tag-based union type
const EventSchema = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("click"),
    x: z.number(),
    y: z.number(),
  }),
  z.object({
    type: z.literal("keypress"),
    key: z.string(),
    ctrlKey: z.boolean(),
  }),
  z.object({
    type: z.literal("scroll"),
    deltaY: z.number(),
  }),
]);

type AppEvent = z.infer<typeof EventSchema>;
// Different fields are automatically inferred based on the type

const clickEvent = EventSchema.parse({ type: "click", x: 100, y: 200 });
console.log(clickEvent); // { type: "click", x: 100, y: 200 }

API Response Validation

Validating data from external APIs with Zod prevents runtime errors caused by type mismatches.

import { z } from "zod";

// API response schema definition
const PostSchema = z.object({
  id: z.number(),
  title: z.string(),
  body: z.string(),
  userId: z.number(),
});

const PostListSchema = z.array(PostSchema);

// Infer API response type
type Post = z.infer<typeof PostSchema>;

// Safe API call function
async function fetchPosts(): Promise<Post[]> {
  const response = await fetch("https://jsonplaceholder.typicode.com/posts");
  const json: unknown = await response.json(); // Receive as unknown

  // Runtime validation: error if schema does not match
  const result = PostListSchema.safeParse(json);
  if (!result.success) {
    console.error("API response format mismatch:", result.error.flatten());
    throw new Error("API response validation failed");
  }

  return result.data; // Post[] type is guaranteed
}

// Environment variable validation
const EnvSchema = z.object({
  DATABASE_URL: z.string().url(),
  API_KEY: z.string().min(10),
  PORT: z.coerce.number().default(3000),
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
});

// Validate environment variables at app startup
function validateEnv() {
  const result = EnvSchema.safeParse(process.env);
  if (!result.success) {
    console.error("Environment variable validation failed:");
    result.error.issues.forEach((issue) => {
      console.error(`  ${issue.path.join(".")}: ${issue.message}`);
    });
    process.exit(1); // Exit immediately on missing env vars
  }
  return result.data;
}

const env = validateEnv();
console.log(`Server port: ${env.PORT}`); // Type-safe environment variable access

Form Validation (React Hook Form Integration)

Zod integrates seamlessly with React Hook Form via @hookform/resolvers.

import { z } from "zod";

// Registration form schema
const RegisterFormSchema = z
  .object({
    username: z
      .string()
      .min(3, "Username must be at least 3 characters")
      .max(20, "Username must be 20 characters or fewer")
      .regex(/^[a-zA-Z0-9_]+$/, "Only letters, numbers, and underscores allowed"),
    email: z.string().email("Please enter a valid email"),
    password: z
      .string()
      .min(8, "Password must be at least 8 characters")
      .regex(/[A-Z]/, "Must contain at least 1 uppercase letter")
      .regex(/[0-9]/, "Must contain at least 1 number"),
    confirmPassword: z.string(),
    agreeTerms: z.literal(true, {
      errorMap: () => ({ message: "You must agree to the terms" }),
    }),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "Passwords do not match",
    path: ["confirmPassword"],
  });

// Infer form data type
type RegisterForm = z.infer<typeof RegisterFormSchema>;

// React Hook Form integration example (pseudo code)
// import { useForm } from "react-hook-form";
// import { zodResolver } from "@hookform/resolvers/zod";
//
// const { register, handleSubmit, formState: { errors } } = useForm<RegisterForm>({
//   resolver: zodResolver(RegisterFormSchema),
// });

// Manual validation example
const formData = {
  username: "alice_doe",
  email: "alice@example.com",
  password: "Secure1234",
  confirmPassword: "Secure1234",
  agreeTerms: true as const,
};

const formResult = RegisterFormSchema.safeParse(formData);
if (formResult.success) {
  console.log("Form validation passed:", formResult.data.username);
  // Form validation passed: alice_doe
} else {
  const fieldErrors = formResult.error.flatten().fieldErrors;
  console.log("Field errors:", fieldErrors);
}

Summary

  • Schema = Type + Validation: Using z.infer to infer types keeps schemas and types always in sync.
  • Use safeParse: For external input, use safeParse which does not throw exceptions.
  • coerce for type conversion: Use z.coerce when converting strings to numbers or dates.
  • refine/superRefine: Use refine for single-field validation and superRefine for cross-field validation.
  • API + Environment variables: Applying Zod validation at external data entry points greatly improves runtime safety.
  • Form library integration: Integrate with React Hook Form, Formik, and others through resolvers.

Was this article helpful?