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.
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 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.
Related articles
- Node.js Node.js Zod Validation Tutorial
Use Zod to validate and infer types for request payloads, environment variables, and external data in Node.js apps.
- Astro Astro Content Collections Tutorial
Build a typed blog with Astro content collections, including Zod schemas, references, and dynamic routes generated from Markdown files.
- FastAPI FastAPI Pydantic Models: A Deep Dive
Master Pydantic models in FastAPI: type coercion, validators, nested models, settings, and tips for clean request and response schemas.
- TypeScript TypeScript Branded Types Tutorial
Use branded types in TypeScript to add nominal safety on top of structural types. Stop mixing up UserIds, emails, and raw strings at compile time.