Zod Showcase

Why Zod?

TypeScript gives you type safety at compile time, but types don't exist at runtime. Once data arrives from an API, a form, or an environment variable, the compiler can't help you. These examples show the problems Zod was built to solve.

TypeScript Can't Protect You at Runtime

TypeScript types only exist at compile time. They are erased when your code runs, which means JSON.parse() always returns any and the compiler will happily accept whatever shape arrives from the network.

Without Zod
// TypeScript trusts you, but can't verify runtime data
const response = await fetch('/api/user');
const user = await response.json(); // type: any ← no safety here

// The compiler is happy with this. But if the API returns
// { id: "123", name: null } instead of { id: 123, name: "Alice" }
// ...this crashes at runtime with no warning:
const formatted = user.id.toFixed(2);
//                         ^^^^^^^^
//                         TypeError: toFixed is not a function

// TypeScript can give you a false sense of security.
With Zod
import { z } from 'zod';

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
});

const response = await fetch('/api/user');

// Throws immediately with a clear error if the shape is wrong:
// "Expected number, received string" at path: id
const user = UserSchema.parse(await response.json());

// Now user.id is guaranteed to be a number
const formatted = user.id.toFixed(2);

Manual Type Guards Are Verbose and Fragile

Writing type guards by hand is tedious, hard to read, and easy to get wrong. Miss one field check and you have a type lie that the compiler will never catch.

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

// Ca. 15 lines to check 3 fields. And it's still too easy to miss one
function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    typeof (value as any).id === 'number' &&
    'name' in value &&
    typeof (value as any).name === 'string' &&
    'email' in value &&
    typeof (value as any).email === 'string'
    // Forgot to check email format? The compiler won't tell you.
  );
}
With Zod
import { z } from 'zod';

// One schema replaces the type + guard entirely
const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.email(), // format validated too
});

// Type-safe and runtime-verified!
const result = UserSchema.safeParse(data);
if (result.success) {
  result.data;
  // type: { id: number; name: string; email: string }
}

Types and Validation Logic Drift Apart

When a TypeScript interface and its runtime validation live in separate places, they will eventually fall out of sync. If you add a field to the interface, forget to update the validator, and the compiler can't help you. Invalid data gets passed through quietly.

Without Zod
// types.ts. The interface lives here
interface User {
  id: number;
  name: string;
  role: 'admin' | 'user' | 'guest'; // ← added 3 months later because access management is usually an afterthought.
}

// validation.ts. The validator lives here, separately
function validateUser(data: unknown): User {
  if (typeof data !== 'object' || data === null) {
    throw new Error('Not an object');
  }
  // 'role' was never added here.
  // Invalid roles ("superadmin") silently pass through as User.
  return data as User; // This is a type cast, not a guarantee
}
With Zod
import { z } from 'zod';

// Schema is the single source of truth. One place to update
export const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  role: z.enum(['admin', 'user', 'guest']),
});

// Type is always derived from the schema. And will never drift
export type User = z.infer<typeof UserSchema>;
// { id: number; name: string; role: "admin" | "user" | "guest" }

// Adding 'superadmin' to the enum will update the type automatically.
// There is no separate interface to keep in sync.