Reusable schemas
From individual schemas to a library
Over the past two lessons, we learned how to derive and combine schemas. You can pick, omit, extend, merge, and make things partial. But all of those examples lived in a single file. In a real project, you have multiple routes, tests, and services that all need the same schemas.
This lesson organizes your schemas into a reusable library that the entire project shares.
Structure
src/
schemas/
common.ts # Shared primitives (email, uuid, pagination)
contact.ts # Contact form schemas
book.ts # Book schemas (create, update, response)
query.ts # Query parameter schemas
index.ts # Re-exports everything Each file handles one domain. Shared building blocks live in common.ts. Domain-specific schemas import from common and build on top.
Common schemas: the building blocks
// src/schemas/common.ts
import { z } from "zod/v4";
// Primitives
export const EmailSchema = z.string().trim().toLowerCase().email();
export const UUIDSchema = z.string().uuid();
export const NonEmptyString = z.string().trim().min(1);
// Pagination (query params)
export const PaginationQuery = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
// Pagination (response)
export const PaginationMeta = z.object({
page: z.number().int(),
limit: z.number().int(),
total: z.number().int(),
totalPages: z.number().int(),
});
// Timestamps
export const Timestamps = z.object({
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
}); These are building blocks. EmailSchema is used in contacts, users, orders, anywhere an email appears. One validator, one definition. If you decide emails should also have a .max(255) constraint, you change it here and every schema that uses it gets the update.
Domain schemas: building on common
// src/schemas/contact.ts
import { z } from "zod/v4";
import { EmailSchema, NonEmptyString } from "./common.js";
export const ContactBase = z.object({
name: NonEmptyString.max(100),
email: EmailSchema,
phone: z.string().trim().min(7).max(20).optional(),
subject: z.enum(["general", "support", "sales", "feedback"]).default("general"),
message: z.string().trim().min(10).max(5000),
});
export const CreateContact = ContactBase;
export const ContactResponse = ContactBase.extend({
id: z.string().uuid(),
createdAt: z.string().datetime(),
});
export type CreateContactInput = z.infer<typeof CreateContact>;
export type ContactResponseType = z.infer<typeof ContactResponse>; Look at what is happening here. ContactBase defines the core fields using NonEmptyString and EmailSchema from common. CreateContact is what the client sends (same as the base). ContactResponse extends it with server-generated fields like id and createdAt.
The types at the bottom are inferred from the schemas. No separate interface definitions. The type and the validation are always in sync because one is derived from the other.
Using schemas in route handlers
// src/routes/contacts.ts
import { route } from "@hectoday/http";
import { CreateContact, ContactResponse } from "../schemas/contact.js";
import { contacts } from "../db.js";
export const contactRoutes = [
route.post("/contacts", {
request: { body: CreateContact },
resolve: (c) => {
if (!c.input.ok) {
return Response.json(
{ error: "Validation failed", details: c.input.issues },
{ status: 400 },
);
}
const contact = {
id: crypto.randomUUID(),
name: c.input.body.name,
email: c.input.body.email,
phone: c.input.body.phone ?? null,
subject: c.input.body.subject,
message: c.input.body.message,
createdAt: new Date().toISOString(),
};
contacts.push(contact);
return Response.json(contact, { status: 201 });
},
}),
]; The route imports the schema. The schema validates the request. The handler only runs with validated, typed data. Compare this to the unvalidated version from the project setup lesson, where c.input.body as any let anything through. Now the schema catches bad data before it reaches the store, and TypeScript knows the exact shape of c.input.body.
Why this matters
One source of truth. The email format is defined in EmailSchema. Every route that validates an email uses it. Change the validation and it updates everywhere.
Types come free. z.infer<typeof CreateContact> gives you the TypeScript type without writing a separate interface. No drift between what you validate and what TypeScript expects.
Tests use the same schemas. Test helpers can generate valid data from schemas, ensuring tests match the actual validation rules.
Documentation stays in sync. If you generate API docs from schemas (like OpenAPI), the docs match the actual validation because they come from the same source.
This is the payoff of everything we have built up over the past several lessons. Individual validators, object schemas, composition, and now organization into a shared library.
In the next section, we put Zod to work in practice: validating all three sources of API input (body, query, path params), formatting errors for clients, and inferring TypeScript types from schemas.
Exercises
Exercise 1: Create a common.ts with EmailSchema, UUIDSchema, and PaginationQuery. Use them in two different domain schemas.
Exercise 2: Create a BookSchema with CreateBook, UpdateBook (partial), and BookResponse (with id and timestamps). Use pick/omit/extend.
Exercise 3: Import the schemas in route handlers. Verify that changing a validator in common.ts propagates to all routes.
What is the main benefit of a schema library?