Zod: The Validation Library That Changed How I Think About Data

Zod: The Validation Library That Changed How I Think About Data
Brandon Perfetti

Technical PM + Software Engineer

Topics:Schema validationData validationTypeScript
Tech:Zod

Zod changed how I plan data flows. Before Zod I sprinkled lightweight type checks across code, trusted runtime shape comments, and added ad-hoc guards where things broke. Zod reframes validation as a single source of truth: a TypeScript-first schema that is both runtime validator and compile-time type generator. In this article I walk through practical patterns: defining schemas and inferring types, choosing between parse and safeParse, applying transforms and preprocessors, handling discriminated unions, and concrete application to API responses, form inputs, and environment variables. Expect code you can copy, paste, and adapt.

1) The Zod mental model: Schema as contract

Zod treats a schema as a contract that does two things: validates runtime data and produces TypeScript types. You write a schema once and use it to guard incoming data and to drive types throughout your application. That alignment reduces duplication and prevents class-of-bugs where runtime shapes drift from static types.

Basic building blocks are primitives and combinators: z.string(), z.number(), z.object({ ... }), z.array(...). Compose these declaratively and Zod will produce both runtime checks and an inferred TypeScript type via z.infer<typeof schema>.

  • Single source of truth: schema is both runtime guard and TypeScript type generator
  • Compositional: small schemas compose into complex ones
  • Declarative: constraints (min, max, regex) live with the shape

2) Defining schemas and getting TypeScript types

Start with a schema and derive types. This makes refactors safer: change the schema, the inferred types update, and the compiler shows affected consumers.

Example: define a user schema, infer the type, and use it in a function signature.

  • Example schema and type inference (TypeScript): import { z } from "zod"; const UserSchema = z.object({ id: z.string().uuid(), name: z.string().min(1), email: z.string().email(), age: z.number().optional(), }); type User = z.infer<typeof UserSchema>; function sendWelcomeEmail(user: User) { // compiler guarantees correct shape }
  • You can export schemas across modules and reuse them in server, client, tests.

3) parse vs safeParse: pick the right failure strategy

Zod provides two primary parsing methods: parse and safeParse. parse throws on validation errors; safeParse returns an object describing success or failure. Use parse in contexts where invalid data should crash fast (e.g., server-side during request handling) or where you want exceptions handled by a central error middleware. Use safeParse where you need granular control over errors, such as form validation or incremental recovery.

Examples below illustrate both approaches and recommended patterns for error extraction.

  • parse throws: simple when a failure should bubble up try { const user = UserSchema.parse(input); } catch (err) { // log and respond 400 }
  • safeParse returns { success, data, error } for programmatic handling const result = UserSchema.safeParse(input); if (!result.success) { const errors = result.error.flatten(); // return structured errors to UI } else { const user = result.data; }

4) Transforms and preprocessing: normalize at the boundary

Often you want to coerce or normalize incoming values. Zod provides z.preprocess and .transform to handle that close to validation. Preprocess runs before validation, useful for parsing strings into numbers or trimming input. transform runs after successful validation and can produce a different type. Use these to keep the rest of your codebase working with clean, predictable types.

Keep transforms simple and idempotent. If you require complex logic, prefer transforming at the edge (adapter layer) and keep schemas focused on shape and constraints.

  • Convert string numbers to numeric types at the boundary: const PositiveNumber = z.preprocess((val) => { if (typeof val === "string") return Number(val); return val; }, z.number().positive());
  • Normalize form strings: const Name = z.string().transform((s) => s.trim());
  • Chaining transforms to derive computed values: const PriceWithTax = z.object({ price: z.number() }).transform(({ price }) => ({ price, priceWithTax: price * 1.08 }));

5) Discriminated unions: robust polymorphic data handling

APIs often return polymorphic objects with a type discriminator field. Zod's discriminatedUnion (also z.union with refinement) makes handling those safe and type-friendly. The discriminator must be a literal field present in all variants, letting Zod narrow types at runtime and TypeScript know which variant you're handling.

Use discriminated unions when response shapes diverge but share a known "kind" or "type" property.

  • Example: API messages with type discriminator const TextMessage = z.object({ type: z.literal("text"), text: z.string() }); const ImageMessage = z.object({ type: z.literal("image"), url: z.string(), alt: z.string().optional() }); const Message = z.discriminatedUnion("type", [TextMessage, ImageMessage]); type Message = z.infer<typeof Message>; function handleMessage(m: Message) { if (m.type === "text") { // m is TextMessage } else { // m is ImageMessage } }
  • Discriminated unions enable exhaustive switches and prevent runtime surprises.

6) Practical examples: API response, form, and env var validation

Below are lean implementations you can adopt. They emphasize where to validate and how to wire Zod into common flows.

a) API response validation: validate inbound JSON from third-party APIs at the boundary. Fail fast and convert responses to typed objects used by the app.

  • API response example: const ApiUser = z.object({ id: z.string(), name: z.string(), email: z.string().email() }); async function fetchUser(id: string) { const res = await fetch(`/api/user/${id}`); const json = await res.json(); const parsed = ApiUser.safeParse(json); if (!parsed.success) { console.error("Invalid API shape", parsed.error); throw new Error("Invalid upstream response"); } return parsed.data; }
  • b) Form validation: use safeParse to give field-level feedback. Combine zod with state management or form libraries. const LoginForm = z.object({ username: z.string().min(1), password: z.string().min(8) }); function onSubmit(values) { const result = LoginForm.safeParse(values); if (!result.success) { const fields = result.error.flatten().fieldErrors; // map field errors to UI } else { // send result.data to server } }
  • c) Environment variable validation: validate process.env early in startup and transform types for runtime use. const EnvSchema = z.object({ NODE_ENV: z.enum(["development", "production", "test"]), PORT: z.preprocess((v) => Number(v), z.number().int().positive()), DATABASE_URL: z.string().url(), }); const env = EnvSchema.parse(process.env); export const PORT = env.PORT;

Conclusion

Zod shifts validation from an afterthought to a first-class contract in TypeScript applications. Schemas give you both runtime safety and compile-time guarantees. Use parse when you want fast failures, safeParse when you need nuanced error handling, preprocess and transform to normalize inputs, and discriminated unions for clear polymorphism. Apply these patterns at the boundaries: API responses, forms, and environment variables. The payoff: fewer runtime surprises, clearer data planning, and code that communicates intent through schema definitions.

Action Checklist

  1. Install zod and add it to a small project: npm install zod. Replace an existing ad-hoc guard with a z.object schema and z.safeParse.
  2. Refactor an API client: validate third-party responses at the fetch layer using Zod schemas to prevent downstream crashes.
  3. Centralize environment parsing: create a single env.ts that z.parse(process.env) and exports typed constants.
  4. Experiment with z.preprocess on form inputs (dates, numbers) to avoid scattered parsing logic in handlers.
  5. Model a polymorphic domain object with z.discriminatedUnion and update handlers to use exhaustive checks.