DEV Community

Alex Spinov
Alex Spinov

Posted on

Zod Has a Free Validation Library: TypeScript-First Schema Validation With Zero Dependencies

You validate form input with if statements. You parse API responses with type assertions. You trust that JSON.parse returns what you expect. Then production crashes because someone sent "123" instead of 123.

What if your validation automatically generated TypeScript types — and caught every malformed input at runtime?

That's Zod. Define a schema once, get both runtime validation AND TypeScript types.

The Core Concept

import { z } from "zod";

// Define schema
const UserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().int().positive().max(150),
  role: z.enum(["admin", "user", "moderator"]),
  tags: z.array(z.string()).max(10).default([]),
});

// Extract TypeScript type — automatically!
type User = z.infer<typeof UserSchema>;
// { name: string; email: string; age: number; role: "admin" | "user" | "moderator"; tags: string[] }

// Validate at runtime
const result = UserSchema.safeParse(unknownData);
if (result.success) {
  console.log(result.data); // Fully typed User
} else {
  console.log(result.error.issues); // Detailed error messages
}
Enter fullscreen mode Exit fullscreen mode

One schema. Both compile-time types and runtime validation. No drift between your TypeScript types and your actual validation logic.

API Input Validation

// Express/Hono/Fastify middleware
app.post("/users", async (req, res) => {
  const result = UserSchema.safeParse(req.body);

  if (!result.success) {
    return res.status(400).json({
      error: "Validation failed",
      issues: result.error.issues.map(i => ({
        field: i.path.join("."),
        message: i.message,
      })),
    });
  }

  // result.data is fully typed User — guaranteed valid
  const user = await db.users.create(result.data);
  return res.json(user);
});
Enter fullscreen mode Exit fullscreen mode

Transform and Coerce

// Transform strings to proper types (common in form data)
const FormSchema = z.object({
  price: z.coerce.number(),  // "49.99" → 49.99
  quantity: z.coerce.number().int().positive(),  // "3" → 3
  date: z.coerce.date(),  // "2026-03-29" → Date object
  active: z.coerce.boolean(),  // "true" → true
});

// Custom transforms
const SlugSchema = z.string()
  .transform(s => s.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, ""));

SlugSchema.parse("Hello World!"); // "hello-world"
Enter fullscreen mode Exit fullscreen mode

Composable Schemas

// Base schema
const BaseEntity = z.object({
  id: z.string().uuid(),
  createdAt: z.coerce.date(),
  updatedAt: z.coerce.date(),
});

// Extend it
const Product = BaseEntity.extend({
  name: z.string(),
  price: z.number().positive(),
  category: z.string(),
});

// Pick fields
const ProductPreview = Product.pick({ id: true, name: true, price: true });

// Make fields optional
const UpdateProduct = Product.partial().omit({ id: true, createdAt: true });

// Merge schemas
const ProductWithReviews = Product.merge(z.object({
  reviews: z.array(ReviewSchema),
  avgRating: z.number().min(0).max(5),
}));
Enter fullscreen mode Exit fullscreen mode

Environment Variable Validation

const EnvSchema = z.object({
  DATABASE_URL: z.string().url(),
  API_KEY: z.string().min(32),
  PORT: z.coerce.number().default(3000),
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
  REDIS_URL: z.string().url().optional(),
});

// Validate at app startup — crash early with clear errors
export const env = EnvSchema.parse(process.env);
// env.PORT is number, not string!
Enter fullscreen mode Exit fullscreen mode

Zod + React Hook Form

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

const SignupSchema = z.object({
  email: z.string().email("Invalid email"),
  password: z.string().min(8, "Min 8 characters"),
  confirmPassword: z.string(),
}).refine(d => d.password === d.confirmPassword, {
  message: "Passwords don't match",
  path: ["confirmPassword"],
});

function SignupForm() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(SignupSchema),
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email")} />
      {errors.email && <span>{errors.email.message}</span>}
      <input type="password" {...register("password")} />
      {errors.password && <span>{errors.password.message}</span>}
      <input type="password" {...register("confirmPassword")} />
      {errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}
      <button type="submit">Sign Up</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

When to Choose Zod

Choose Zod when:

  • You use TypeScript and want type-safe validation
  • You validate API inputs, form data, or environment variables
  • You want one schema for both types and validation
  • Zero dependencies matters (Zod has none)

Skip Zod when:

  • You don't use TypeScript (Joi or Yup work for plain JS)
  • Performance-critical hot paths (Zod is slower than hand-written validators)
  • You only need simple type checks (typeof/instanceof might suffice)

The Bottom Line

Zod turns "I hope this data is correct" into "I know this data is correct." One schema, two guarantees: TypeScript types at compile time, validation at runtime.

Start here: zod.dev


Need custom data extraction, scraping, or automation? I build tools that collect and process data at scale — 78 actors on Apify Store and 265+ open-source repos. Email me: Spinov001@gmail.com | My Apify Actors

Top comments (0)