DEV Community

websilvercraft
websilvercraft

Posted on

What is Zod and what does it bring over typescript type definitions

Zod is a framework that covers a gap that happens because typescript gets compiled into javascript. Typescript being strongly typed, but java not, you are covered are compile-time for type checks, but that is lost at run time. Zod is here to do perform type checks at runtime plus adding data validations and even transformation.

The Core Difference: Compile-Time vs Runtime

TypeScript Types = Compile-Time Only

// This type disappears after compilation
interface User {
  name: string;
  age: number;
}

// TypeScript thinks this is fine at compile time
const userData: User = await fetch('/api/user').then(r => r.json());
console.log(userData.name); // πŸ’₯ Runtime error if API returns { username: "John" }
Enter fullscreen mode Exit fullscreen mode

Zod = Runtime Validation + Type Inference

import { z } from 'zod';

// This exists at runtime AND generates TypeScript types
const UserSchema = z.object({
  name: z.string(),
  age: z.number()
});

// This will throw at runtime if data doesn't match
const userData = UserSchema.parse(await fetch('/api/user').then(r => r.json()));
console.log(userData.name); // βœ… Guaranteed to exist and be a string
Enter fullscreen mode Exit fullscreen mode

What Zod Adds: The Advantages

1. Runtime Safety for External Data

// ❌ TypeScript alone - false confidence
interface APIResponse {
  users: Array<{ id: number; email: string }>;
}

// TypeScript: "Looks good!" 
// Reality: API might return { users: null } or { data: [...] }
const response: APIResponse = await fetchAPI();
response.users.map(...); // πŸ’₯ Cannot read property 'map' of null

// βœ… With Zod - actual validation
const APIResponseSchema = z.object({
  users: z.array(z.object({
    id: z.number(),
    email: z.string().email() // Even validates email format!
  }))
});

try {
  const response = APIResponseSchema.parse(await fetchAPI());
  response.users.map(...); // βœ… Guaranteed safe
} catch (error) {
  // Handle malformed response
}
Enter fullscreen mode Exit fullscreen mode

2. Single Source of Truth

// ❌ TypeScript - duplicate definitions
interface User {
  email: string;
  age: number;
}

function validateUser(data: any): data is User {
  return typeof data.email === 'string' && 
         typeof data.age === 'number' &&
         data.age >= 0; // Oops, forgot this in the type!
}

// βœ… Zod - one definition, both type and validation
const UserSchema = z.object({
  email: z.string().email(),
  age: z.number().min(0)
});

type User = z.infer<typeof UserSchema>; // Type derived from schema
const isValid = UserSchema.safeParse(data).success; // Validation from same source
Enter fullscreen mode Exit fullscreen mode

3. Rich Validation Beyond Types

// ❌ TypeScript can't express these constraints
interface Password {
  value: string; // Can't say "min 8 chars, must have uppercase"
}

// βœ… Zod can validate complex rules
const PasswordSchema = z.string()
  .min(8, "Password must be at least 8 characters")
  .regex(/[A-Z]/, "Must contain uppercase letter")
  .regex(/[0-9]/, "Must contain number")
  .regex(/[^A-Za-z0-9]/, "Must contain special character");
Enter fullscreen mode Exit fullscreen mode

4. Transformation and Coercion

// βœ… Zod can transform data while validating
const ConfigSchema = z.object({
  port: z.string().transform(Number), // "3000" β†’ 3000
  enabled: z.string().transform(s => s === "true"), // "true" β†’ true
  createdAt: z.string().pipe(z.coerce.date()), // "2024-01-01" β†’ Date object
  email: z.string().toLowerCase().trim().email() // Clean and validate
});

// URL params, form data, environment variables - all strings!
const config = ConfigSchema.parse({
  port: "3000",
  enabled: "true", 
  createdAt: "2024-01-01",
  email: "  USER@EXAMPLE.COM  "
});
// Result: { port: 3000, enabled: true, createdAt: Date, email: "user@example.com" }
Enter fullscreen mode Exit fullscreen mode

5. Better Error Messages

// ❌ TypeScript at runtime
JSON.parse(apiResponse); // Error: Unexpected token < in JSON at position 0

// βœ… Zod validation errors
UserSchema.parse(data);
/* ZodError: {
  "issues": [{
    "path": ["email"],
    "message": "Invalid email format"
  }, {
    "path": ["age"],
    "message": "Expected number, received string"
  }]
} */
Enter fullscreen mode Exit fullscreen mode

6. Composability

// Build complex schemas from simple ones
const AddressSchema = z.object({
  street: z.string(),
  city: z.string(),
  zip: z.string().regex(/^\d{5}$/)
});

const PersonSchema = z.object({
  name: z.string(),
  addresses: z.array(AddressSchema), // Reuse schemas
  primaryAddress: AddressSchema.optional()
});

const CompanySchema = z.object({
  employees: z.array(PersonSchema), // Compose further
  headquarters: AddressSchema
});
Enter fullscreen mode Exit fullscreen mode

Real-World Example: Your HeadingAnalyzer

// What could go wrong without runtime validation?

// 1. DOM parsing might produce unexpected results
const heading = {
  level: 7, // ❌ TypeScript won't catch this at runtime
  text: null, // ❌ Might be null instead of empty string
  position: -1, // ❌ Could be negative
  id: undefined // ❌ Might be undefined instead of null
};

// 2. Cloudflare Worker receives malformed data
const apiResponse = await fetch('/analyze');
const data: HeadingAnalysisResult = await apiResponse.json();
// TypeScript: "Looks good!"
// Reality: Could be { error: "Rate limited" } or literally anything

// 3. With Zod, you catch these issues immediately
const HeadingInfoSchema = z.object({
  level: z.number().min(1).max(6), // Must be 1-6
  text: z.string(), // Coerces null to empty string
  position: z.number().int().nonnegative(), // Must be positive integer
  id: z.string().nullable() // Explicitly nullable, not undefined
});
Enter fullscreen mode Exit fullscreen mode

When You NEED Zod

  1. API Boundaries: Validating external API responses
  2. User Input: Form data, URL params, file uploads
  3. Config Files: Environment variables, JSON configs
  4. Database Results: Ensuring DB schema matches expectations
  5. Webhooks: Third-party services sending data
  6. localStorage/sessionStorage: Stored data might be corrupted

When TypeScript Types Are Enough

  1. Internal Code: Functions calling other functions you control
  2. Build-Time Known: Imported JSON, constants
  3. Trusted Sources: Your own internal services with guaranteed contracts
  4. Performance Critical: Hot paths where validation overhead matters

In conclusion, TypeScript protects you from yourself (typos, wrong types in your code). Zod protects you from the world (APIs, users, databases, external systems).

Top comments (1)

Collapse
 
ingosteinke profile image
Ingo Steinke, web developer • Edited

I discovered Zod as Astro recommends or expects it when using MDX front matter content collections with TypeScript, and I found it very useful. Despite the "single source of truth", I still have more than a single place to edit my type definitions, e.g.

// content.config.ts (Zod)
icon: z.enum(['book', 'blogpost']).default('book').optional(),
Enter fullscreen mode Exit fullscreen mode

and

// Book.tsx (TypeScript JSX)
interface CardProps {
  icon?: 'book'|'blogpost';
Enter fullscreen mode Exit fullscreen mode

I wondered if there is an elegant way to avoid this data duplication. Update: I eventually found out about type inference like this ...

// Book.tsx
import type { z } from 'zod';
import { CardSchema } from './content.config';

export type CardProps = z.infer<typeof CardSchema>;
Enter fullscreen mode Exit fullscreen mode

... but still struggled to make it work.