Validation per version
Input shapes change too
So far we’ve focused on response shapes: how data goes out. But request shapes, what clients send in, also change between versions. v1 accepts author_id (snake_case), v2 accepts authorId (camelCase). v1 accepts genre as any string, v2 validates it against a specific list.
Each version needs its own Zod schemas.
v1 schemas
// src/v1/schemas.ts
import { z } from "zod/v4";
export const CreateBookV1 = z.object({
title: z.string().trim().min(1).max(200),
author_id: z.string().min(1),
genre: z.string().min(1),
description: z.string().max(5000).optional(),
});
export const UpdateBookV1 = CreateBookV1.partial();
export const BookQueryV1 = z.object({
genre: z.string().optional(),
sort: z.enum(["title", "created_at"]).default("title"),
}); Look at the field names: author_id with an underscore, matching v1’s snake_case convention. Genre is a free string, so the client can send anything. The sort options use snake_case too.
UpdateBookV1 is created by calling .partial() on the create schema, which makes every field optional. That way, a PATCH request only needs to include the fields being changed.
v2 schemas
// src/v2/schemas.ts
import { z } from "zod/v4";
export const CreateBookV2 = z.object({
title: z.string().trim().min(1).max(200),
authorId: z.string().uuid(),
genre: z.enum(["fiction", "science-fiction", "fantasy", "non-fiction", "other"]),
description: z.string().max(5000).optional(),
});
export const UpdateBookV2 = CreateBookV2.partial();
export const BookQueryV2 = z.object({
genre: z.enum(["fiction", "science-fiction", "fantasy", "non-fiction", "other"]).optional(),
sort: z.enum(["title", "createdAt", "rating"]).default("title"),
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
}); Notice the differences. authorId is camelCase and validated as a UUID. In v1, author_id accepted any string. v2 is stricter. Genre is now an enum, not a free string. If the client sends genre: "romance", validation fails because it’s not in the allowed list. Sort options use camelCase. And v2 adds pagination with page and limit, something v1 doesn’t support at all.
[!NOTE] The Zod for Beginners course built schemas with
.pick(),.omit(), and.partial()for create/update variants. The same patterns apply here, but each version has its own base schema because the field names and validations differ.
Using schemas in routes
Here’s where it all comes together. Each version uses its own schema to validate the request body:
// src/v1/routes.ts
import { CreateBookV1 } from "./schemas.js";
route.post("/v1/books", {
request: { body: CreateBookV1 },
resolve: (c) => {
if (!c.input.ok) {
return Response.json(
{ error: "Validation failed", details: c.input.issues },
{ status: 400 },
);
}
// c.input.body has: title, author_id, genre, description
const { body } = c.input;
const book = createBook({
title: body.title,
authorId: body.author_id, // map snake_case to internal
genre: body.genre,
description: body.description,
});
return Response.json(formatBookV1(book), { status: 201 });
},
});
// src/v2/routes.ts
import { CreateBookV2 } from "./schemas.js";
route.post("/v2/books", {
request: { body: CreateBookV2 },
resolve: (c) => {
if (!c.input.ok) {
return Response.json(
{ error: "Validation failed", details: c.input.issues },
{ status: 400 },
);
}
// c.input.body has: title, authorId, genre, description
const { body } = c.input;
const book = createBook({
title: body.title,
authorId: body.authorId, // already camelCase
genre: body.genre,
description: body.description,
});
return Response.json(formatBookV2(book), { status: 201 });
},
}); Both routes call the same createBook function with the same internal shape. The difference is in the mapping. v1’s schema gives us author_id (snake_case), so we map it to authorId (camelCase) for the internal function. v2’s schema already uses authorId, so no mapping is needed.
Try creating a book through v1:
curl -X POST http://localhost:3000/v1/books \
-H "Content-Type: application/json" \
-d '{"title": "Parable of the Sower", "author_id": "author-1", "genre": "science-fiction"}' Now try v2:
curl -X POST http://localhost:3000/v2/books \
-H "Content-Type: application/json" \
-d '{"title": "Parable of the Sower", "authorId": "author-1", "genre": "science-fiction"}' What do you think happens if you send a v1-shaped body to the v2 endpoint? Try it:
curl -X POST http://localhost:3000/v2/books \
-H "Content-Type: application/json" \
-d '{"title": "Parable of the Sower", "author_id": "author-1", "genre": "science-fiction"}' The validation fails. author_id isn’t a valid field in the v2 schema, and authorId is missing. Each version enforces its own contract.
Shared creation logic
The function that actually creates the book doesn’t know or care about versions:
// src/shared/mutations.ts
import db from "./db.js";
import type { BookRow } from "./queries.js";
interface CreateBookInput {
title: string;
authorId: string;
genre: string;
description?: string;
}
export function createBook(input: CreateBookInput): BookRow {
const id = crypto.randomUUID();
db.prepare(
"INSERT INTO books (id, title, author_id, genre, description) VALUES (?, ?, ?, ?, ?)",
).run(id, input.title, input.authorId, input.genre, input.description ?? null);
return getBookById(id)!;
} The internal interface uses a consistent naming convention, camelCase. v1 schemas map author_id to authorId. v2 schemas already use authorId. The shared function receives the same shape regardless of which version called it.
This is the pattern. Version-specific schemas handle the external contract. Shared functions handle the internal logic. The two layers are cleanly separated.
Next, we’ll look at how to evolve an API without creating a new version at all. Not every change needs a version bump.
Exercises
Exercise 1: Create v1 and v2 Zod schemas for book creation. Test that v1 accepts author_id and v2 accepts authorId.
Exercise 2: Send a v1-shaped body to the v2 endpoint. Verify it fails validation (wrong field names).
Exercise 3: Add pagination to the v2 query schema but not v1. Verify v2 supports ?page=2&limit=10 while v1 ignores those params.
Why do v1 and v2 need separate Zod schemas?