The Problem
Daria's API accepts user input but validates it with manual if checks scattered through handlers. Types drift from reality: the TypeScript type says email: string but the runtime value could be anything. The same rules are re-typed in the form, the tRPC procedures and the database layer, and they disagree.
The Solution
Define each shape once with Zod 4 (zod@4, imported as "zod") and derive everything else from it: TypeScript types with z.infer, request validation, tRPC .input()/.output(), form validation through @hookform/resolvers, and insert schemas for Drizzle tables through drizzle-zod. Zod 4 deprecates several spellings that older tutorials still use (z.string().email(), error.errors, .flatten()); this guide uses the current ones.
npm install zod @trpc/server drizzle-orm drizzle-zod react-hook-form @hookform/resolvers
Step-by-Step Walkthrough
Step 1: Define shared schemas
// src/schemas/user.ts
import { z } from "zod";
export const UserRoleSchema = z.enum(["admin", "editor", "viewer"]);
export const CreateUserSchema = z.object({
email: z.email("Invalid email address"),
name: z.string().min(2, "Name must be at least 2 characters").max(100),
role: UserRoleSchema.default("viewer"),
password: z
.string()
.min(10, "Password must be at least 10 characters")
.regex(/[A-Z]/, "Must contain an uppercase letter")
.regex(/[0-9]/, "Must contain a number"),
});
export const UpdateUserSchema = CreateUserSchema.omit({ password: true }).partial();
export const UserResponseSchema = z.object({
id: z.uuid(),
email: z.email(),
name: z.string(),
role: UserRoleSchema,
createdAt: z.coerce.date(),
plan: z.enum(["free", "pro", "enterprise"]),
});
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
export type UserResponse = z.infer<typeof UserResponseSchema>;
// src/schemas/project.ts
import { z } from "zod";
export const ProjectStatusSchema = z.enum(["active", "paused", "completed", "archived"]);
export const CreateProjectSchema = z.object({
name: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
status: ProjectStatusSchema.default("active"),
budget: z.number().positive().optional(),
tags: z.array(z.string().max(50)).max(10).default([]),
deadline: z.coerce.date().optional(),
}).refine((p) => !p.deadline || p.deadline > new Date(), {
error: "Deadline must be in the future",
path: ["deadline"],
});
export const ProjectFiltersSchema = z.object({
status: ProjectStatusSchema.optional(),
search: z.string().max(200).optional(),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sortBy: z.enum(["name", "createdAt", "deadline"]).default("createdAt"),
sortOrder: z.enum(["asc", "desc"]).default("desc"),
});
export type CreateProjectInput = z.output<typeof CreateProjectSchema>;
export type ProjectFilters = z.output<typeof ProjectFiltersSchema>;
Notes: z.email() and z.uuid() replace the deprecated z.string().email() / .uuid(). The deadline check is a .refine on the object, because a new Date() passed to .min() is evaluated once at module load and would freeze "now" at server start. Use z.output<> for types of schemas with defaults or coercion (what the handler receives); z.input<> is what callers may send.
Step 2: Validate API requests and responses
// src/lib/api-validate.ts
import { z } from "zod";
export class ApiError extends Error {
constructor(public status: number, message: string, public fields?: Record<string, string[]>) {
super(message);
}
}
export function validateRequest<T extends z.ZodType>(schema: T, data: unknown): z.output<T> {
const result = schema.safeParse(data);
if (!result.success) {
throw new ApiError(400, "Validation failed", z.flattenError(result.error).fieldErrors as Record<string, string[]>);
}
return result.data;
}
// src/app/api/users/route.ts (Next.js route handler)
import { CreateUserSchema, UserResponseSchema } from "@/schemas/user";
import { ApiError, validateRequest } from "@/lib/api-validate";
export async function POST(req: Request) {
try {
const input = validateRequest(CreateUserSchema, await req.json());
const user = await createUser(input);
return Response.json(UserResponseSchema.parse(user), { status: 201 });
} catch (error) {
if (error instanceof ApiError) {
return Response.json({ error: error.message, fields: error.fields }, { status: error.status });
}
throw error;
}
}
A bad body returns 400 with {"error":"Validation failed","fields":{"email":["Invalid email address"]}}. Parsing the response with UserResponseSchema catches handlers that leak fields or return the wrong shape before the client sees them; never return the password or its hash.
Step 3: Fail fast on environment variables
// src/env.ts
import { z } from "zod";
const EnvSchema = z.object({
DATABASE_URL: z.url(),
JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
PORT: z.coerce.number().int().positive().default(3000),
});
const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
console.error(z.prettifyError(parsed.error));
process.exit(1);
}
export const env = parsed.data;
env.PORT is a number and env.NODE_ENV is a union, with no casts. A missing JWT_SECRET prints a readable ✖ ... → at JWT_SECRET message and the process exits at boot instead of failing on the first request. Keep vendor key checks (such as startsWith("sk_")) out of the schema unless you control the format.
Step 4: Reuse the schemas in tRPC
The same schemas are tRPC inputs and outputs, so the client gets the types with no extra work:
// src/trpc.ts
import { initTRPC } from "@trpc/server";
import { CreateUserSchema, UserResponseSchema } from "@/schemas/user";
import { ProjectFiltersSchema } from "@/schemas/project";
const t = initTRPC.create();
export const appRouter = t.router({
createUser: t.procedure
.input(CreateUserSchema)
.output(UserResponseSchema)
.mutation(async ({ input }) => {
// input: { email: string; name: string; role: "admin" | ...; password: string }
return { id: crypto.randomUUID(), email: input.email, name: input.name, role: input.role, createdAt: new Date(), plan: "free" as const };
}),
listProjects: t.procedure
.input(ProjectFiltersSchema) // ?page=2 style strings are coerced by the schema
.query(({ input }) => ({ items: [], page: input.page })),
});
export type AppRouter = typeof appRouter;
An invalid input is rejected with a BAD_REQUEST tRPC error before the resolver runs.
Step 5: Derive database validation from the Drizzle table
// src/db/schema.ts
import { integer, pgTable, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: uuid().primaryKey().defaultRandom(),
email: varchar({ length: 255 }).notNull().unique(),
name: varchar({ length: 100 }).notNull(),
role: text({ enum: ["admin", "editor", "viewer"] }).notNull().default("viewer"),
passwordHash: text().notNull(),
createdAt: timestamp().defaultNow().notNull(),
});
// src/schemas/db.ts: the row shape that is safe to insert
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod";
import { users } from "@/db/schema";
export const InsertUserRow = createInsertSchema(users, {
email: (s) => s.email(),
name: (s) => s.min(2).max(100),
}).omit({ id: true, createdAt: true });
export type InsertUserRowT = z.infer<typeof InsertUserRow>;
createInsertSchema builds the validator from the column types (the role enum, notNull columns); the callback form tightens individual columns. Hashing happens between CreateUserSchema (plain password) and this schema (passwordHash), so the two stay separate on purpose. An unknown role fails with Invalid option: expected one of "admin"|"editor"|"viewer".
Step 6: Form validation (React Hook Form + Zod)
// src/components/CreateProjectForm.tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { CreateProjectSchema } from "@/schemas/project";
type FormIn = z.input<typeof CreateProjectSchema>;
type FormOut = z.output<typeof CreateProjectSchema>;
export function CreateProjectForm({ onSubmit }: { onSubmit: (data: FormOut) => Promise<void> }) {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormIn, unknown, FormOut>({
resolver: zodResolver(CreateProjectSchema),
defaultValues: { status: "active", tags: [] },
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name")} />
{errors.name && <p>{errors.name.message}</p>}
<input type="number" {...register("budget", { setValueAs: (v) => (v === "" ? undefined : Number(v)) })} />
<input type="date" {...register("deadline", { setValueAs: (v) => v || undefined })} />
{errors.deadline && <p>{errors.deadline.message}</p>}
<button type="submit" disabled={isSubmitting}>Create</button>
</form>
);
}
zodResolver in @hookform/resolvers 5+ takes the schema's input and output types separately, hence the three generics. The setValueAs callbacks turn an empty field into undefined; without them an empty date becomes Invalid Date and an empty number becomes NaN, and the optional fields fail.
Step 7: Composable building blocks
// src/schemas/common.ts
import { z } from "zod";
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 function paginatedResponse<T extends z.ZodType>(itemSchema: T) {
return z.object({
items: z.array(itemSchema),
pagination: z.object({ page: z.number(), limit: z.number(), total: z.number(), pages: z.number() }),
});
}
export const IdParamSchema = z.object({ id: z.uuid("Invalid ID format") });
export const SlugSchema = z.string().min(3).max(100).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Must be lowercase with hyphens");
export const EmailSchema = z.email().transform((e) => e.trim().toLowerCase());
Real-World Example
Daria moves the four hand-written checks in POST /api/users onto CreateUserSchema, points the signup form and the createUser tRPC mutation at the same schema, and adds env.ts to the entry point. Deleting a rule now means editing one line, and a type error shows up in every consumer. In staging, a deploy missing JWT_SECRET stopped at boot with a one-line message instead of failing on the first login.
Related Skills
zod— schema definition, parsing, coercion and Zod 4 migration detailstrpc— typed procedures that take Zod schemas as inputs and outputsdrizzle-orm— table definitions thatdrizzle-zodturns into insert and select schemas