Zod: Runtime Type Checking That TypeScript Should Have Built In

Here is a problem that has bitten me more times than I want to admit. You define a TypeScript interface for your API response. You write your fetch call, type the response, and everything looks perfect in your editor. Green squiggles everywhere. Then at runtime, the API returns a field as a string instead of a number, or omits a field entirely, and your app blows up in production.
TypeScript types are erased at compile time. They exist only in your editor and your build step. At runtime, there is no trace of them. Your carefully crafted interfaces are gone. This is by design. TypeScript compiles down to plain JavaScript, and JavaScript has no concept of your interface definitions.
This means every boundary in your application, where data enters from the outside world (API responses, form submissions, URL parameters, database queries, WebSocket messages), is a trust boundary that TypeScript cannot protect. You need runtime validation. And that is exactly what Zod does.
Schema-First Approach
Zod flips the typical TypeScript workflow. Instead of defining a type first and then writing validation logic separately, you define a Zod schema and infer the TypeScript type from it. One source of truth for both runtime validation and compile-time types.
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string().min(1),
email: z.string().email(),
role: z.enum(['admin', 'user', 'editor']),
createdAt: z.string().datetime(),
});
// Infer the TypeScript type from the schema
type User = z.infer<typeof UserSchema>;
// User is now equivalent to:
// {
// id: number;
// name: string;
// email: string;
// role: 'admin' | 'user' | 'editor';
// createdAt: string;
// }
The z.infer utility extracts the TypeScript type from any Zod schema. This is the key insight. You never write the type manually. The schema is the type. If you change the schema, the inferred type updates automatically. No drift between your validation logic and your type definitions.
Parsing vs. Validation
Zod makes an important distinction between parsing and validation. The .parse() method takes unknown input and either returns the typed, validated data or throws a ZodError. The .safeParse() method does the same thing but returns a result object instead of throwing.
// Throws on invalid input
const user = UserSchema.parse(apiResponse);
// Returns { success: true, data: User } or { success: false, error: ZodError }
const result = UserSchema.safeParse(apiResponse);
if (result.success) {
console.log(result.data.name); // fully typed
} else {
console.log(result.error.issues); // array of validation errors
}
I prefer safeParse in almost every situation. Throwing errors for expected validation failures feels wrong. You expect user input to be invalid sometimes. That is not an exceptional case. safeParse treats validation as a normal control flow operation, which is what it is.
Composing Schemas
Zod schemas compose cleanly. You can extend objects, merge them, pick specific fields, or omit fields. This mirrors the utility types you already know from TypeScript.
const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true });
// { name: string, email: string, role: 'admin' | 'user' | 'editor' }
const UpdateUserSchema = CreateUserSchema.partial();
// All fields optional
const AdminSchema = UserSchema.extend({
permissions: z.array(z.string()),
});
Union types and discriminated unions are where Zod really shines for complex domain modeling:
const ShapeSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('circle'), radius: z.number() }),
z.object({ type: z.literal('rectangle'), width: z.number(), height: z.number() }),
z.object({ type: z.literal('triangle'), base: z.number(), height: z.number() }),
]);
type Shape = z.infer<typeof ShapeSchema>;
// Properly narrows to the correct variant based on the 'type' field
Transforms and Pipelines
Zod is not just about validation. You can chain transforms to coerce and reshape data as part of the parsing pipeline. This is incredibly useful when dealing with form data (which is always strings) or API responses that need normalization.
const DateStringSchema = z.string()
.transform((val) => new Date(val))
.refine((date) => !isNaN(date.getTime()), { message: 'Invalid date' });
const PaginationSchema = z.object({
page: z.string().transform(Number).pipe(z.number().int().positive()),
limit: z.string().transform(Number).pipe(z.number().int().min(1).max(100)),
});
// Input: { page: '3', limit: '25' }
// Output: { page: 3, limit: 25 } (typed as { page: number, limit: number })
The .pipe() method lets you chain schemas together. Parse the string, transform it to a number, then validate the number. Each step feeds into the next. The final inferred type reflects the output of the last schema in the pipeline.
Form Validation
Form validation is one of the most common use cases for Zod, especially when paired with React Hook Form. The @hookform/resolvers package includes a Zod resolver that plugs directly into React Hook Form's validation system.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const SignupSchema = z.object({
username: z.string().min(3, 'Username must be at least 3 characters'),
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
type SignupForm = z.infer<typeof SignupSchema>;
function SignupPage() {
const { register, handleSubmit, formState: { errors } } = useForm<SignupForm>({
resolver: zodResolver(SignupSchema),
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('username')} />
{errors.username && <span>{errors.username.message}</span>}
{/* ...other fields */}
</form>
);
}
The .refine() method handles cross-field validation. You cannot express "password must match confirmPassword" with individual field validators alone. Refinements run after all field-level validations pass and can check relationships between fields.
API Request Validation
On the server side, Zod is perfect for validating incoming request bodies. Every API endpoint that accepts user input should validate it at the boundary before passing it into your business logic.
// In an Express route handler
app.post('/api/users', async (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
errors: result.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
})),
});
}
// result.data is fully typed as CreateUser
const user = await createUser(result.data);
return res.status(201).json(user);
});
The error formatting is customizable. Zod's ZodError contains an issues array where each issue has a path (which field failed), a message, and a code indicating the type of validation failure. You can transform these into whatever error response format your API uses.
Integration with tRPC
Zod is the default validation library for tRPC, which I cover in a separate post. In tRPC, Zod schemas define the input types for your procedures, and the types flow automatically from server to client. The pairing is so natural that it feels like they were designed together.
const appRouter = router({
createUser: publicProcedure
.input(CreateUserSchema)
.mutation(async ({ input }) => {
// input is fully typed as CreateUser
return db.user.create({ data: input });
}),
});
Error Formatting
Zod's built-in error formatting is detailed but verbose. For user-facing error messages, you usually want to flatten the errors into a simpler structure. Zod provides .flatten() and .format() methods for this:
const result = UserSchema.safeParse(badData);
if (!result.success) {
const flat = result.error.flatten();
// { formErrors: string[], fieldErrors: { name?: string[], email?: string[] } }
}
When Not to Use Zod
Zod is not free. Each schema carries runtime overhead for parsing. For hot paths that process millions of records, you might want something lighter like a simple type guard function. For internal data that never crosses a trust boundary (data you already validated upstream), re-validating with Zod adds overhead with no benefit.
The sweet spot is at the boundaries: API endpoints, form submissions, environment variable parsing, configuration files, and any data coming from external sources. Validate once at the boundary, trust the types internally.
Zod fills a real gap in the TypeScript ecosystem. Types should not disappear at the exact moment when you need them most, which is when real data flows through your application. With Zod, they do not have to.