TL;DR: TypeScript types vanish at runtime. Zod gives you real runtime validation that keeps your types and data in sync — no more blind trust in API responses or user input.
Zod – TypeScript-First Schema Validation
Zod is a TypeScript-first schema declaration and validation library. Define schemas once, infer types automatically, validate at runtime.

🤔 The Problem: TypeScript Is Lying to You

If you've been working with TypeScript for a while, you know the feeling: you define a clean interface, type everything properly — and it still blows up at runtime. Why? Because TypeScript types only exist at compile time. Once your code runs in the browser or on a server, all types are gone. Just like that. Poof.

This means: if your API returns something unexpected, if a user enters garbage into a form, or if your environment variables are missing — TypeScript has no clue. It compiles happily and leaves you hanging.

interface User {
  id: number;
  name: string;
  email: string;
}

// This compiles just fine...
const response = await fetch("/api/user/1");
const user = (await response.json()) as User;

// ...but what if the API returns { id: "abc", name: null }?
// TypeScript says: "All good!" — Runtime says: 💥
console.log(user.email.toLowerCase()); // Cannot read properties of undefined

That as User is the most dangerous thing you can write in TypeScript. It's not a cast like in Java or C# — it's a promise to the compiler that you can't keep. You're saying: "Trust me, this is a User." And the compiler believes you blindly.

Why You Should Only Use TypeScript
TypeScript gives you type safety, better tooling and fewer bugs.

⚠️ Why as Casts Are Dangerous

Let's walk through this concretely. Imagine you're building an app that fetches user data from an external API:

// ❌ Unfortunately, many developers do this
async function getUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  return (await res.json()) as User; // "Trust me, bro"
}

// What happens when the API response looks like this?
// { id: 42, username: "max", mail: "[email protected]" }
// → name is undefined, email is undefined
// → TypeScript notices NOTHING

The problem gets worse when you use as with more complex structures. You build logic on false assumptions, and the error surfaces three function calls later — or worse: never, and you write broken data to the database.

🛡️ Enter Zod: Your Runtime Bodyguard

Zod is a TypeScript-first schema validation library. Instead of blindly trusting, you define a schema that validates at runtime whether your data matches what you expect. And the best part: Zod automatically infers the TypeScript type from it — a single source of truth.

import { z } from "zod";

// Define the schema
const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

// Infer the TypeScript type — no dual maintenance!
type User = z.infer<typeof UserSchema>;

// Now it ACTUALLY validates
async function getUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  const data = await res.json();
  return UserSchema.parse(data); // Throws on invalid data!
}

Installing Zod is straightforward:

npm install zod
# or
pnpm add zod
# or
yarn add zod

⚙️ Zod Basics: The Essential Primitives

Zod comes with validators for all standard types — and every validator is chainable with additional constraints:

import { z } from "zod";

// Strings with constraints
const nameSchema = z.string().min(2, "Name must be at least 2 characters").max(100);
const emailSchema = z.string().email("Invalid email address");
const urlSchema = z.string().url();
const uuidSchema = z.string().uuid();

// Numbers with bounds
const ageSchema = z.number().int().min(0).max(150);
const priceSchema = z.number().positive().finite();

// Booleans, Dates, Enums
const isActiveSchema = z.boolean();
const createdAtSchema = z.date();
const roleSchema = z.enum(["admin", "user", "moderator"]);

// Optional and nullable fields
const bioSchema = z.string().optional(); // string | undefined
const avatarSchema = z.string().nullable(); // string | null
const nicknameSchema = z.string().nullish(); // string | null | undefined

// Default values
const pageSchema = z.number().default(1);
const limitSchema = z.number().default(20);

🏗️ Objects and Arrays

Zod's real strength shines with complex structures:

const AddressSchema = z.object({
  street: z.string(),
  city: z.string(),
  zip: z.string().regex(/^\d{5}$/, "ZIP must be 5 digits"),
  country: z.string().default("US"),
});

const UserSchema = z.object({
  id: z.number().int().positive(),
  name: z.string().min(1),
  email: z.string().email(),
  role: z.enum(["admin", "user", "moderator"]),
  addresses: z.array(AddressSchema).min(1, "At least one address required"),
  tags: z.array(z.string()).default([]),
  metadata: z.record(z.string(), z.unknown()).optional(),
  createdAt: z.string().datetime(),
});

type User = z.infer<typeof UserSchema>;
// This gives you exactly the type you'd expect — including
// optional fields, defaults, and nested structures.

You can also define tuples and unions:

// Tuples: fixed length, different types
const coordinatesSchema = z.tuple([z.number(), z.number()]);

// Discriminated Union: the safe way for different variants
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() }),
  z.object({ type: z.literal("scroll"), delta: z.number() }),
]);

type Event = z.infer<typeof EventSchema>;
// Event is automatically: { type: "click"; x: number; y: number } | { type: "keypress"; key: string } | ...

🔧 Schema Composition: Reuse Instead of Copy-Paste

One of Zod's biggest advantages is composability. Instead of writing schemas from scratch, you can extend, merge, or extract parts from existing schemas:

// Base schema
const BaseUserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
});

// Extend with extend()
const CreateUserSchema = BaseUserSchema.extend({
  password: z.string().min(8),
  confirmPassword: z.string(),
}).refine(
  (data) => data.password === data.confirmPassword,
  { message: "Passwords don't match", path: ["confirmPassword"] }
);

// Response schema — with ID and timestamps
const UserResponseSchema = BaseUserSchema.extend({
  id: z.number(),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
});

// Subsets with pick() and omit()
const UserNameEmailSchema = UserResponseSchema.pick({ name: true, email: true });
const UserWithoutTimestampsSchema = UserResponseSchema.omit({ createdAt: true, updatedAt: true });

// Make everything optional with partial()
const UpdateUserSchema = BaseUserSchema.partial();
// Results in: { name?: string; email?: string }

// Merge two schemas with merge()
const ProfileSchema = z.object({ bio: z.string(), avatar: z.string().url() });
const FullUserSchema = UserResponseSchema.merge(ProfileSchema);
Understanding and Using REST APIs
What REST APIs are, how they work, and how to use them in your projects.

💻 Practical: Validating API Responses

Now let's get practical. Here's a realistic example of how you'd use Zod in a real project:

// schemas/api.ts
import { z } from "zod";

const PaginationSchema = z.object({
  page: z.number().int().positive(),
  perPage: z.number().int().positive(),
  total: z.number().int().nonnegative(),
  totalPages: z.number().int().nonnegative(),
});

const ProductSchema = z.object({
  id: z.string().uuid(),
  name: z.string(),
  price: z.number().positive(),
  currency: z.enum(["EUR", "USD", "GBP"]),
  inStock: z.boolean(),
  categories: z.array(z.string()),
  description: z.string().optional(),
});

const ProductListResponseSchema = z.object({
  data: z.array(ProductSchema),
  pagination: PaginationSchema,
});

type Product = z.infer<typeof ProductSchema>;
type ProductListResponse = z.infer<typeof ProductListResponseSchema>;

// API client with validation
async function fetchProducts(page = 1): Promise<ProductListResponse> {
  const res = await fetch(`/api/products?page=${page}`);

  if (!res.ok) {
    throw new Error(`API error: ${res.status}`);
  }

  const json = await res.json();
  return ProductListResponseSchema.parse(json);
}

🚀 .parse() vs .safeParse(): Error Handling Done Right

Zod offers two ways to validate — and you should know when to use which:

const UserSchema = z.object({
  name: z.string(),
  age: z.number().min(0),
  email: z.string().email(),
});

// ❌ parse() — throws an exception on invalid data
try {
  const user = UserSchema.parse(unknownData);
  // user is guaranteed valid and correctly typed here
} catch (error) {
  if (error instanceof z.ZodError) {
    console.error("Validation failed:", error.issues);
    // [{ code: "invalid_type", expected: "string", received: "undefined", path: ["name"], message: "Required" }]
  }
}

// ✅ safeParse() — returns a result object (no throw!)
const result = UserSchema.safeParse(unknownData);

if (result.success) {
  // result.data is correctly typed as User
  console.log(result.data.name);
} else {
  // result.error is a ZodError with all the details
  const formatted = result.error.format();
  console.log(formatted.name?._errors); // ["Required"]
  console.log(formatted.email?._errors); // ["Invalid email"]
}

Rule of thumb: Use .parse() when invalid data is a real error (e.g., broken API response). Use .safeParse() when you expect invalid data and want to handle it gracefully (e.g., form validation where you want to show error messages to the user).

🌍 Practical: Validating Environment Variables

A classic scenario: your app starts, but a critical environment variable is missing. Instead of discovering this three hours later in production, validate at startup:

// env.ts
import { z } from "zod";

const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
  PORT: z.string().transform(Number).pipe(z.number().int().positive()),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"),
  REDIS_URL: z.string().url().optional(),
  CORS_ORIGINS: z.string().transform((s) => s.split(",").map((o) => o.trim())),
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});

// Validate at app startup
function validateEnv() {
  const result = EnvSchema.safeParse(process.env);

  if (!result.success) {
    console.error("❌ Invalid environment variables:");
    for (const issue of result.error.issues) {
      console.error(`  ${issue.path.join(".")}: ${issue.message}`);
    }
    process.exit(1);
  }

  return result.data;
}

export const env = validateEnv();
// env.PORT is now number, not string!
// env.CORS_ORIGINS is string[], not string!

Notice the .transform() + .pipe() trick: process.env always delivers strings. With transform you convert the string to a number, and with pipe you validate the result again.

Connecting Custom Forms to Google Spreadsheets with Apps Script
Turn any Google Spreadsheet into a free form backend with Google Apps Script.

📝 Practical: Form Validation

Zod is perfect for form validation — especially combined with libraries like React Hook Form:

import { z } from "zod";

const ContactFormSchema = z.object({
  firstName: z.string().min(2, "At least 2 characters"),
  lastName: z.string().min(2, "At least 2 characters"),
  email: z.string().email("Please enter a valid email"),
  phone: z.string().regex(/^\+?[\d\s-]{6,}$/, "Invalid phone number").optional(),
  subject: z.enum(["support", "sales", "feedback", "other"]),
  message: z.string()
    .min(10, "Message must be at least 10 characters")
    .max(5000, "Message must be at most 5000 characters"),
  acceptTerms: z.literal(true, {
    errorMap: () => ({ message: "You must accept the terms" }),
  }),
});

type ContactForm = z.infer<typeof ContactFormSchema>;
// React Hook Form Integration
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

function ContactPage() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<ContactForm>({
    resolver: zodResolver(ContactFormSchema),
  });

  const onSubmit = (data: ContactForm) => {
    // data is fully validated and typed here!
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("firstName")} />
      {errors.firstName && <span>{errors.firstName.message}</span>}
      {/* ... more fields */}
    </form>
  );
}
NestJS – A Progressive Node.js Framework
NestJS is a framework for building efficient, scalable Node.js server-side applications with TypeScript support out of the box.
NestJS: Server Framework on Steroids
NestJS is a powerful server framework for Node.js with a TypeScript-first approach.

🏢 Integration with NestJS

If you're using NestJS (and if you've read my NestJS article, you might be), you can use Zod instead of class-validator for DTOs. With nestjs-zod, it's super smooth:

// dto/create-user.dto.ts
import { createZodDto } from "nestjs-zod";
import { z } from "zod";

const CreateUserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  password: z.string().min(8).regex(
    /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
    "Password needs uppercase, lowercase, and a number"
  ),
  role: z.enum(["user", "admin"]).default("user"),
});

export class CreateUserDto extends createZodDto(CreateUserSchema) {}

// controller
import { Controller, Post, Body, UsePipes } from "@nestjs/common";
import { ZodValidationPipe } from "nestjs-zod";

@Controller("users")
export class UsersController {
  @Post()
  @UsePipes(ZodValidationPipe)
  create(@Body() dto: CreateUserDto) {
    // dto is validated!
    return this.usersService.create(dto);
  }
}

The advantage over class-validator: you have a single schema that defines both the TypeScript type and the validation. No decorator chaos, no dual maintenance.

🎨 Zod vs. the Alternatives

Feature Zod Yup Joi io-ts
TypeScript-first ✅ Yes ⚠️ Partial ❌ No ✅ Yes
Type Inference ✅ Excellent ⚠️ Limited ❌ No ✅ Good
Bundle Size ~14 KB ~16 KB ~75 KB ~7 KB + fp-ts
API Design Simple & intuitive Simple Comprehensive Functional (steep)
Learning Curve Flat Flat Medium Steep
Ecosystem 🔥 Growing fast Established Established (Node) Niche
0 Dependencies ❌ (fp-ts)

My recommendation: For new TypeScript projects, Zod is the best choice. The type inference is unbeatable, the API is intuitive, and the ecosystem is growing rapidly. Yup is fine if you're already using it — but for new projects? Zod, hands down.

🧪 Bonus: Advanced Patterns

Before we wrap up, here are some advanced techniques that'll help you in daily work:

// 1. Preprocessing: Transform data BEFORE validation
const SearchParamsSchema = z.object({
  query: z.string().min(1),
  page: z.preprocess(
    (val) => (typeof val === "string" ? parseInt(val, 10) : val),
    z.number().int().positive()
  ),
  sort: z.enum(["name", "date", "price"]).default("date"),
});

// 2. Recursive Types: Validate tree structures
type Category = z.infer<typeof CategorySchema>;
const CategorySchema: z.ZodType<Category> = z.lazy(() =>
  z.object({
    id: z.string(),
    name: z.string(),
    children: z.array(CategorySchema).default([]),
  })
);

// 3. Custom Error Messages with errorMap
const StrictStringSchema = z.string({
  required_error: "This field is required",
  invalid_type_error: "Must be a string",
}).min(1, "Cannot be empty");

// 4. Branded Types: Type safety on the next level
const UserId = z.string().uuid().brand<"UserId">();
const OrderId = z.string().uuid().brand<"OrderId">();

type UserId = z.infer<typeof UserId>;
type OrderId = z.infer<typeof OrderId>;

// This prevents accidentally using an OrderId as a UserId
function getUser(id: UserId) { /* ... */ }

const userId = UserId.parse("550e8400-e29b-41d4-a716-446655440000"); // ✅
const orderId = OrderId.parse("550e8400-e29b-41d4-a716-446655440001");
// getUser(orderId); // ❌ TypeScript error! OrderId !== UserId

💡 Conclusion

TypeScript types are great for developer experience — but they don't protect you from harsh runtime reality. Every API response, every user input, every environment variable is potentially "unsafe" — and that's exactly where Zod steps in.

Key takeaways:

  • Never trust external data — validate everything that comes from outside
  • as casts are not protection — they're a promise you can't keep
  • Zod is your single source of truth — one schema, one type, one validation
  • .safeParse() for expected errors, .parse() for unexpected ones
  • Validate at the boundary of your application — where external data enters

Stop blindly trusting your APIs. Your future self will thank you. 🚀

Artikel teilen:Share article: