Skip to content
C Codeloom
TypeScript

TypeScript with Zod Validation

Use Zod with TypeScript to validate runtime data and infer types from a single source of truth. Cover schemas, parsing, transforms, and error handling.

·5 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • Why TypeScript alone is not enough for external data
  • How Zod schemas double as types via z.infer
  • Parsing, safeParse, transforms, and refinements
  • Composing schemas for APIs, forms, and configs
  • Practical error handling patterns

Prerequisites

  • Basic TypeScript
  • Familiarity with async APIs

What and Why

TypeScript checks code at compile time, but it cannot check data that arrives at runtime. JSON from an API, parsed query strings, environment variables, and form inputs all enter your program as unknown from the type system’s perspective. Trusting them blindly is how production crashes happen.

Zod is a schema validation library that solves this with a single artifact: a schema. You declare the shape once and Zod gives you both a runtime parser and a static TypeScript type. There is no duplication, no drift between validators and types, and no manual casts at the boundary. Bad data is rejected before it can corrupt your business logic.

Mental Model

A Zod schema is a value that knows how to validate. Calling schema.parse(input) returns a typed value or throws. Calling z.infer<typeof schema> extracts a TypeScript type from the same schema, so types follow validators automatically.

Treat schemas as the boundary layer of your application. Everything inside the boundary works with strict, typed values. Everything outside, including the network and the disk, must pass through a schema first. This single rule eliminates an entire class of “but it had the right shape in the test” bugs.

Hands-on Example

A typical API response schema looks like this.

import { z } from 'zod';

const User = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  age: z.number().int().min(0).max(150),
  role: z.enum(['admin', 'member']),
  createdAt: z.coerce.date(),
});

type User = z.infer<typeof User>;

async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  return User.parse(await res.json());
}

User.parse throws a ZodError if anything fails. The returned value is typed exactly as the schema describes, including the Date produced by z.coerce.date() from a string field.

For non-throwing flows, use safeParse.

const result = User.safeParse(input);
if (!result.success) {
  console.error(result.error.flatten());
  return;
}
const user = result.data; // typed User

Schemas compose freely.

const Page = <T extends z.ZodTypeAny>(item: T) =>
  z.object({
    items: z.array(item),
    nextCursor: z.string().nullable(),
  });

const UsersPage = Page(User);
type UsersPage = z.infer<typeof UsersPage>;

A generic Page schema works for any item type, and the inferred type comes along for free.

            +-----------------+
          |   Zod schema    |
          |   (one source)  |
          +--------+--------+
                   |
       +-----------+-----------+
       |                       |
       v                       v
schema.parse()           z.infer<typeof schema>
(runtime check)          (compile-time type)
       |                       |
       v                       v
 Validated data           Static User type
 throws on bad input      drives autocomplete
Single source of truth: schema, validator, type

Transforms and refinements let you go beyond shape checks.

const Slug = z.string()
  .min(3)
  .regex(/^[a-z0-9-]+$/)
  .transform(s => s.toLowerCase());

const Password = z.string().refine(
  s => s.length >= 8 && /[0-9]/.test(s),
  { message: 'Need 8+ chars and a digit' },
);

Transforms change the output type. Refinements add custom rules without changing the type.

Common Pitfalls

The biggest mistake is parsing too late. If you call parse after a value has already flowed through helpers, the helpers were untyped and you have already lost the safety. Parse at the boundary, immediately.

A second pitfall is mixing input and output types when transforms are involved. The schema’s input type (z.input) and output type (z.infer) can differ. Forms and APIs that round-trip data need both, not just one.

Avoid z.any() and z.unknown() inside schemas. They defeat the entire purpose. If a field really is dynamic, use z.record with a value schema, or a tagged union with z.discriminatedUnion.

Beware of performance with very large schemas. Each parse walks the whole shape. For hot paths, cache parsed values or split schemas so you parse only what you need.

Finally, ZodError objects are verbose. Use error.flatten() for forms and error.issues for logs. Do not stringify the whole error in user-facing UIs.

Best Practices

Declare schemas next to the modules that use them, then export the inferred type. Importers get the type for free without depending on Zod themselves if you re-export carefully.

Validate environment variables at startup with a single z.object and crash on failure. A bad config should never reach business logic.

Use z.discriminatedUnion for tagged variants. It is faster than plain unions and produces clearer error messages.

Pair Zod with branded types when you need both runtime validation and nominal safety. The transform output can be branded so the rest of the codebase sees, for example, ValidEmail instead of string.

Write a small parseOrThrow(schema, input) helper to keep call sites tidy, and a safeParseOrLog variant for non-critical paths.

Wrap-up

Zod closes the gap between TypeScript’s compile-time guarantees and the messy data your program actually receives. One schema gives you a validator, a type, and a transform pipeline, eliminating the drift between runtime checks and static types.

Parse at every boundary, infer types from schemas, and let bad data fail fast with clear errors. With Zod handling the perimeter, the interior of your TypeScript codebase can stay as strict and confident as the compiler always pretended it was.