Schema validation with Zod
In the previous chapter, you wrote validation by hand, one if statement at a time. The more fields you add, the more this code grows, and the "type" and the "validation rules" end up managed in two places. In this chapter you learn Zod, which gives you both runtime validation and a static type from a single schema definition.
What you learn in this chapter
- Define a schema with Zod and derive a type with
z.infer - Combine it with React Hook Form to validate forms in a type-safe way
- Use
safeParseto validate external data such as API responses at runtime - API changes in Zod v4 to watch out for
This chapter uses zod and @hookform/resolvers for the React Hook Form integration. Zod was given a major update to v4 in 2025, and this chapter assumes v4.
What is Zod
Zod is a TypeScript-first schema declaration and validation library. Writing a single schema gives you two things at once:
- Runtime validation: check at runtime whether a value matches the schema
- A static type: derive a TypeScript type from the schema (no hand-written
typedefinition needed)
TypeScript types disappear at compile time, so values that are only known at runtime—such as user input or API responses—cannot be protected by types alone. Zod fills this gap.
Installation
npm install zod
# When combining with React Hook Form
npm install react-hook-form @hookform/resolvers
Schema basics
Declare the shape of an object with z.object().
import { z } from 'zod'
const userSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.email('Invalid email address'),
age: z.number().int().min(0).max(120),
role: z.enum(['admin', 'member']),
})
- Use
z.string()/z.number()for primitives and add constraints with.min()/.max() z.email()validates the email format (in Zod v4 this moved fromz.string().email()to the top-levelz.email())z.enum()restricts the allowed values- You can pass an error message as an argument to each method
Run validation with parse (throws on failure) or safeParse (returns the failure as a value). For forms and external data, safeParse is easier to work with because you can branch on the result instead of relying on exceptions.
const result = userSchema.safeParse({
name: 'Taro',
email: 'taro@example.com',
age: 30,
role: 'member',
})
if (result.success) {
console.log(result.data) // the validated value (typed)
} else {
console.log(result.error.issues) // which field failed and why
}
Deriving a type with z.infer
Extract a type from the schema with z.infer. Keeping the type and the validation rules in one place is the heart of Zod.
type User = z.infer<typeof userSchema>
// Equivalent to:
// { name: string; email: string; age: number; role: 'admin' | 'member' }
When you hand-write a type, it is easy to forget to update it after changing a validation rule. With z.infer, the type follows automatically once you edit the schema.
Integrating with React Hook Form
Pass zodResolver from @hookform/resolvers/zod to useForm, and your Zod schema becomes the form's validation directly.
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const signupSchema = z.object({
email: z.email('Invalid email address'),
password: z.string().min(8, 'Must be at least 8 characters'),
})
type SignupValues = z.infer<typeof signupSchema>
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<SignupValues>({
resolver: zodResolver(signupSchema),
})
const onSubmit = (data: SignupValues) => {
// data is a type-safe value that has passed the schema
console.log(data)
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input type="email" {...register('email')} />
{errors.email && <span role="alert">{errors.email.message}</span>}
</div>
<div>
<input type="password" {...register('password')} />
{errors.password && <span role="alert">{errors.password.message}</span>}
</div>
<button type="submit" disabled={isSubmitting}>Sign up</button>
</form>
)
}
Compared with the hand-written validation in the previous chapter, notice that the validation logic is consolidated into the schema, and the message you wrote in the schema goes straight into errors.email.message.
For the React Hook Form API itself (register / handleSubmit / uncontrolled components), see the form libraries section of the previous chapter. This chapter focuses on the Zod side.
Runtime validation of API responses
Zod's value is not limited to forms. JSON received via fetch only self-declares its type in TypeScript; there is no guarantee it actually has that shape.
// A common pattern: nothing is validated at runtime
const res = await fetch('/api/users/1')
const user = (await res.json()) as User // as can lie
If the shape changes due to an API change or a backend bug, as will not notice. Running the data through safeParse lets you guarantee the shape at runtime.
async function fetchUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`)
if (!res.ok) throw new Error('Failed to fetch')
const json = await res.json()
const parsed = userSchema.safeParse(json)
if (!parsed.success) {
// Not the expected shape = treat it as a contract violation with the backend
throw new Error('Invalid response format')
}
return parsed.data // type-safe from here on
}
If you call safeParse inside the queryFn of TanStack Query, you can validate external data before it enters the cache. Think of it as replacing the type assertion (as) with runtime validation.
A note on Zod v4
Zod was given a major update to v4 in 2025, and some APIs have changed from v3. Many articles online assume v3, so be sure to check the current API in the Zod official documentation.
- String formats moved to the top level:
z.string().email()→z.email()(the same applies toz.url()/z.uuid(), etc.) - Unified error customization:
{ message: ... }was consolidated into{ error: ... }(messagestill works for now but is deprecated) - React Hook Form integration: use the Zod v4-compatible version of
@hookform/resolvers/zod
Summary
- From a single schema, Zod gives you both runtime validation and a static type (
z.infer) zodResolverlets you consolidate React Hook Form validation into the schemasafeParsealso works for runtime validation of external data such as API responses- For a deeper dive from the type-inference angle, see Writing React with TypeScript as well
What to read next
You now have the basics of forms and validation. Next, move on to useEffect, which handles side effects.