TypeScript catches a lot of bugs. But it has a blind spot: the network boundary. Your server returns { createdAt: string } instead of { created_at: string }, and TypeScript won't say a word. You'll find out at runtime, when the UI renders "undefined" where a date should be.
This happens because TypeScript types are erased at compile time. They describe what your code expects, not what actually arrives over the wire. Your frontend type says User, but the API could return anything -- a different shape, extra fields, missing fields, wrong types. TypeScript just trusts you.
Zod fixes this by making validation and types the same thing. You define a schema once, in a shared folder, and both sides of the app use it. The server validates incoming requests against it. The client validates forms against it. TypeScript infers types from it. One source of truth, enforced at runtime and compile time.
The Shared Schema
Start with a /shared folder at the project root. Both server and client import from here:
/shared/schemas/user.ts
/server/routes/users.ts ← imports from shared
/client/features/users/ ← imports from shared
Here's a user creation schema:
// shared/schemas/user.ts
import { z } from "zod";
export const CreateUserSchema = z.object({
email: z.string().email("Invalid email address"),
name: z.string().min(2, "Name must be at least 2 characters"),
role: z.enum(["admin", "member", "viewer"]),
avatarUrl: z.string().url().optional(),
});
export type CreateUser = z.infer<typeof CreateUserSchema>;
// → { email: string; name: string; role: "admin" | "member" | "viewer"; avatarUrl?: string }
z.infer extracts the TypeScript type from the schema. You never write the type by hand. If you add a field to the schema, the type updates automatically, and every file that imports CreateUser gets type-checked against the new shape.
Server: Fastify Request Validation
On the backend, use the schema to validate incoming request bodies. Invalid data gets rejected before your route handler ever runs:
// server/routes/users.ts
import { CreateUserSchema } from "../../shared/schemas/user.js";
fastify.post("/api/users", async (request, reply) => {
const parsed = CreateUserSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: "Validation failed",
issues: parsed.error.issues,
});
}
// parsed.data is fully typed as CreateUser
const user = await prisma.user.create({
data: {
email: parsed.data.email,
name: parsed.data.name,
role: parsed.data.role,
avatarUrl: parsed.data.avatarUrl,
},
});
return reply.status(201).send(user);
});
safeParse returns either { success: true, data: CreateUser } or { success: false, error: ZodError }. No try/catch needed. The data inside parsed.data is guaranteed to match the schema -- not just typed, actually validated.
Client: React Hook Form + Mantine
The same schema drives form validation on the frontend. With @hookform/resolvers/zod, you connect the Zod schema directly to React Hook Form:
// client/features/users/CreateUserForm.tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { TextInput, Select, Button } from "@mantine/core";
import { CreateUserSchema, type CreateUser } from "../../../shared/schemas/user.js";
export function CreateUserForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<CreateUser>({
resolver: zodResolver(CreateUserSchema),
});
const onSubmit = async (data: CreateUser) => {
// data is validated and typed -- matches exactly what the server expects
await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<TextInput
label="Email"
{...register("email")}
error={errors.email?.message}
/>
<TextInput
label="Name"
{...register("name")}
error={errors.name?.message}
/>
<Select
label="Role"
data={["admin", "member", "viewer"]}
{...register("role")}
error={errors.role?.message}
/>
<Button type="submit">Create User</Button>
</form>
);
}
The error messages you defined in the schema ("Invalid email address", "Name must be at least 2 characters") show up directly in the form. No duplication. If you change a validation rule in the schema, both the server rejection message and the client form error update at the same time.
What Happens When You Change a Field
Say you rename role to accessLevel in the schema:
export const CreateUserSchema = z.object({
email: z.string().email("Invalid email address"),
name: z.string().min(2, "Name must be at least 2 characters"),
accessLevel: z.enum(["admin", "member", "viewer"]), // renamed
avatarUrl: z.string().url().optional(),
});
Run tsc and you'll immediately see errors in every file that references role -- the server route, the form component, any tests. Nothing slips through. This is the whole point: the schema is the contract, and TypeScript enforces it at compile time across both sides.
Advanced Patterns
.transform(): Reshape Data at Parse Time
Normalize data as it enters the system. The schema describes both the input and the output:
export const CreateUserSchema = z.object({
email: z.string().email().transform((e) => e.toLowerCase().trim()),
name: z.string().min(2),
role: z.enum(["admin", "member", "viewer"]),
});
Now parsed.data.email is always lowercase and trimmed, regardless of what the client sends. The transform runs during safeParse, so your route handler never deals with messy input.
.refine(): Custom Validation Logic
For rules that go beyond basic type checks:
export const DateRangeSchema = z
.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
})
.refine((data) => data.endDate > data.startDate, {
message: "End date must be after start date",
path: ["endDate"],
});
The path option tells React Hook Form which field to attach the error to. The form shows the message under the end date input without any extra wiring.
Discriminated Unions for API Responses
Type-safe API responses where the structure depends on a status field:
export const ApiResponse = z.discriminatedUnion("status", [
z.object({ status: z.literal("success"), data: UserSchema }),
z.object({ status: z.literal("error"), message: z.string(), code: z.number() }),
]);
type ApiResponse = z.infer<typeof ApiResponse>;
// TypeScript knows: if status === "success", then .data exists
// if status === "error", then .message and .code exist
This pattern eliminates if (response.data) guessing. You check status, and TypeScript narrows the type automatically.
Error Handling: Zod Errors to User-Friendly Messages
Zod's error structure is designed for programmatic consumption. Each issue includes a path (which field), a code (what went wrong), and a message (human-readable). You can map these to form fields directly:
function formatErrors(error: z.ZodError): Record<string, string> {
const formatted: Record<string, string> = {};
for (const issue of error.issues) {
const field = issue.path.join(".");
if (!formatted[field]) {
formatted[field] = issue.message;
}
}
return formatted;
}
// Server returns: { issues: [{ path: ["email"], message: "Invalid email address", code: "invalid_string" }] }
// formatErrors turns it into: { email: "Invalid email address" }
This means your server's 400 response can be displayed directly in the form -- the same error format, whether validation runs client-side or server-side.
Project Structure
Here's how the shared schemas fit into a fullstack monorepo:
shared/
schemas/
user.ts # CreateUserSchema, UpdateUserSchema, UserSchema
project.ts # CreateProjectSchema, ProjectFilters, etc.
auth.ts # LoginSchema, RegisterSchema
common.ts # PaginationSchema, SortSchema, IdParam
index.ts # Re-exports everything
server/
routes/
users.ts # imports CreateUserSchema for validation
projects.ts
client/
features/
users/
CreateUserForm.tsx # imports CreateUserSchema for form validation
UserList.tsx
projects/
shared/schemas/common.ts is where you put reusable pieces:
// shared/schemas/common.ts
export const PaginationSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
export const IdParamSchema = z.object({
id: z.string().cuid(),
});
Every route that accepts pagination params uses the same schema. Every form that includes an ID validates it the same way. One change propagates everywhere.
What This Eliminates
The pattern removes an entire class of bugs that TypeScript alone can't catch:
- Field renames that break one side but not the other
- Type mismatches between what the API sends and what the UI expects
- Duplicated validation with rules that drift out of sync
- Runtime surprises from unvalidated external data
The cost is one extra dependency and a shared/ folder. The return is that your frontend and backend are provably talking about the same data shapes -- checked by the compiler, enforced at runtime, defined in one place.
Top comments (0)