DEV Community

DevForge Templates
DevForge Templates

Posted on

One Schema, Zero Drift: How Zod Keeps My Frontend and Backend in Sync

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
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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(),
});
Enter fullscreen mode Exit fullscreen mode

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"]),
});
Enter fullscreen mode Exit fullscreen mode

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"],
  });
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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" }
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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(),
});
Enter fullscreen mode Exit fullscreen mode

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)