TypeScript's type system catches a significant category of errors at compile time, but it cannot prevent a user from submitting an empty email field or a password that is two characters long. Type safety and input validation are complementary concerns, not interchangeable ones. TypeScript ensures the code handles data of the right shape. Runtime validation ensures the data from the user actually has that shape before the code tries to use it.
Zod bridges the two by letting a schema define both the runtime validation rules and the TypeScript type at the same time. The schema is the single source of truth: define it once, validate against it at runtime, and infer the TypeScript type from it automatically.
This guide builds a signup form that applies this pattern end to end, then extends it to server-side validation in a Next.js API route using the same schema.
What this covers:
Why runtime validation is necessary alongside TypeScript
Choosing between Zod, Yup, and Valibot
Defining a Zod schema and inferring the TypeScript type from it
Integrating validation into a React form
Reusing the same schema for server-side validation in Next.js
Using React Hook Form with Zod for larger forms
Why Both Static and Runtime Validation Are Needed
TypeScript operates at compile time. It enforces that the code handles a SignupData object correctly — that the right properties are accessed, that functions receive the expected types, and so on. But by the time the code runs in the browser, TypeScript's guarantees no longer apply. The data coming from a form input is a string regardless of what the TypeScript types say.
Runtime validation handles the gap. It takes the raw input, checks that it matches the expected shape and constraints, and returns either validated data or a structured set of errors.
Validation type | Example tool | When it runs | What it prevents |
|---|---|---|---|
Static | TypeScript | Compile time | Type mismatches in code |
Runtime | Zod, Yup, Valibot | User input | Invalid or malformed data reaching business logic |
Both are necessary. Static types alone leave the application vulnerable to bad input. Runtime validation alone misses the category of bugs TypeScript catches before the code runs.
Choosing a Validation Library
Three libraries dominate TypeScript-first form validation:
Zod generates TypeScript types directly from schema definitions with z.infer. The type and the validation rules stay in sync automatically. The API is concize and the error messages are structured for programmatic access.
Yup has a larger existing ecosystem and a more declarative syntax that some developers find more readable. TypeScript support is good but types are not inferred as cleanly as Zod's.
Valibot is modular and tree-shakeable, making it significantly smaller in bundle size. Worth considering for performance-sensitive applications where bundle size is a primary concern.
This guide uses Zod. The patterns apply to Yup with minor syntax differences.
Step 1: Define the Schema and Infer the Type
Install Zod:
npm install zod
Define the schema in a shared location so it can be imported by both the client form and the server route:
// lib/schemas/signup.ts
import { z } from "zod";
export 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")
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
"Password must contain uppercase, lowercase, and a number"
),
});
export type SignupData = z.infer<typeof signupSchema>;
signupSchema handles runtime validation. SignupData is the TypeScript type, inferred directly from the schema. There is no manual type definition to keep in sync with the validation rules. Changing a field in the schema updates the type automatically.
Step 2: Validate in a React Form
// components/SignupForm.tsx
import { useState } from "react";
import { signupSchema } from "@/lib/schemas/signup";
type FieldErrors = Partial<Record<keyof typeof signupSchema.shape, string>>;
export default function SignupForm() {
const [errors, setErrors] = useState<FieldErrors>({});
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = e.currentTarget;
const formData = {
username: (form.elements.namedItem("username") as HTMLInputElement).value,
email: (form.elements.namedItem("email") as HTMLInputElement).value,
password: (form.elements.namedItem("password") as HTMLInputElement).value,
};
const result = signupSchema.safeParse(formData);
if (!result.success) {
const fieldErrors: FieldErrors = {};
result.error.errors.forEach((err) => {
const field = err.path[0] as keyof FieldErrors;
if (field) fieldErrors[field] = err.message;
});
setErrors(fieldErrors);
return;
}
setErrors({});
console.log("Validated data:", result.data);
// result.data is typed as SignupData
}
return (
<form onSubmit={handleSubmit}>
<div>
<input name="username" placeholder="Username" />
{errors.username && <p>{errors.username}</p>}
</div>
<div>
<input name="email" type="email" placeholder="Email" />
{errors.email && <p>{errors.email}</p>}
</div>
<div>
<input name="password" type="password" placeholder="Password" />
{errors.password && <p>{errors.password}</p>}
</div>
<button type="submit">Sign Up</button>
</form>
);
}
A few things worth noting in this implementation:
form.elements.namedItemwith an explicit cast is safer than(e.target as any).fieldName.value. Theas anycast in the original disables type checking entirely for those lines.safeParsereturns either{ success: true, data: SignupData }or{ success: false, error: ZodError }. The data in the success branch is fully typed asSignupData, so any code that usesresult.datagets correct TypeScript types.The
FieldErrorstype constrains the error keys to the actual field names of the schema rather than allowing any string.
Step 3: Reuse the Schema on the Server
The same schema validates the request body in the API route, ensuring client and server validation rules are identical:
// app/api/signup/route.ts
import { NextResponse } from "next/server";
import { signupSchema } from "@/lib/schemas/signup";
export async function POST(req: Request): Promise<NextResponse> {
const body = await req.json();
const result = signupSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ errors: result.error.format() },
{ status: 400 }
);
}
// result.data is typed as SignupData
const { username, email, password } = result.data;
// proceed with user creation...
return NextResponse.json({ success: true });
}
result.error.format() structures the errors by field name, which makes it straightforward to display server-side errors in the same format as client-side errors if needed.
Sharing the schema between client and server means a validation rule change in one place updates both automatically. The alternative is maintaining separate validation logic in two locations, which drifts over time.
Step 4: Using React Hook Form with Zod (for Larger Forms)
For forms with many fields, controlled state management, or more complex validation requirements, React Hook Form with the Zod resolver reduces boilerplate significantly:
npm install react-hook-form @hookform/resolvers
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { signupSchema, SignupData } from "@/lib/schemas/signup";
export default function SignupForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<SignupData>({
resolver: zodResolver(signupSchema),
});
function onSubmit(data: SignupData) {
console.log("Validated data:", data);
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input {...register("username")} placeholder="Username" />
{errors.username && <p>{errors.username.message}</p>}
</div>
<div>
<input {...register("email")} type="email" placeholder="Email" />
{errors.email && <p>{errors.email.message}</p>}
</div>
<div>
<input {...register("password")} type="password" placeholder="Password" />
{errors.password && <p>{errors.password.message}</p>}
</div>
<button type="submit">Sign Up</button>
</form>
);
}
zodResolver connects the Zod schema to React Hook Form's validation lifecycle. The errors object from formState is typed with the field names from SignupData, so TypeScript will flag errors.nonExistentField as an error.
Key Takeaways
TypeScript validates code structure at compile time. Runtime validation libraries like Zod validate user input at runtime. Both are necessary for a complete solution.
Defining a Zod schema and using
z.inferto derive the TypeScript type means the type and validation rules are always in sync. Changing one updates the other automatically.safeParsereturns a discriminated union. Theresult.datain the success branch is fully typed, and theresult.errorin the failure branch provides structured, field-level error messages.Sharing the schema between client and server validation keeps the rules consistent and eliminates the maintenance overhead of two separate validation implementations.
React Hook Form with
zodResolverreduces boilerplate for larger or more complex forms and provides field-level error handling with full TypeScript support.
Conclusion
The combination of a Zod schema for runtime validation and z.infer for the TypeScript type is one of the more elegant patterns in the TypeScript ecosystem. It reduces the total amount of code required, keeps types and validation rules in sync automatically, and produces structured errors that are easy to map to form fields.
The pattern scales from a simple contact form to a complex multi-step form with server-side validation, and the core approach stays the same at every scale.
Using a different validation library or a specific Zod pattern that has worked well? Share it in the comments.




