Building the Bookstore REST API with Express: Routers, Validation with zod, Async Error Handling and Problem Details
A solid REST API with Express separates its layers, validates every request with a zod schema and derives the type from it, turns errors into RFC 9457 Problem Details in one middleware, and keeps money as whole numbers of kuruş instead of floats. With real output: an order total of 1339.1999999999998, a float sum drifting by 5 kuruş over 20 million lines, validation returning six errors in one response, the same Problem Details contract as ASP.NET Core, paging, and z.toJSONSchema.
Version note: The examples in this post were run with Node.js 26.13.1 (LTS), Express 5.3.0, zod 4.6.7 and TypeScript 7.0.2.
Guess First
In Bookstore, the online bookstore used as the shared example across this site's series, Ada buys three copies of Dune (349.90 TRY) and one The Hobbit (289.50 TRY). Up to post 11, the API computed the order total like this:
// The order total as the API computed it up to post 11: prices as JavaScript numbers
const items = [
{ isbn: "9780441172719", title: "Dune", quantity: 3, unitPrice: 349.9 },
{ isbn: "9780547928227", title: "The Hobbit", quantity: 1, unitPrice: 289.5 },
];
const total = items.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0);
console.log(total);
console.log(JSON.stringify({ total }));
console.log(total === 1339.2);
On paper the answer is 1,339.20. What do the three lines print, and what ends up in the JSON sent to the client?
node labs/12/guess.ts
1339.1999999999998
{"total":1339.1999999999998}
false
JavaScript's only number type, number, is a 64-bit binary floating point value. 349.90 cannot be represented exactly in binary, multiplication makes the error visible, and JSON.stringify sends it to the client as it is. In C# the same calculation uses decimal and gives 1339.20; JavaScript has no decimal. In this post we turn the Bookstore API into a real API: we keep prices as whole numbers of kuruş, split the code into route → handler → service → repository layers, validate requests with zod and return errors in the Problem Details format ASP.NET Core uses.
Money: Whole Numbers of Kuruş
There are three ways to hold money correctly in JavaScript: integers (kuruş), BigInt or a decimal library. Bookstore's amounts are small: Number.MAX_SAFE_INTEGER is about 9 quadrillion, so integer kuruş amounts are exact up to 90 trillion lira. So we chose the simplest: priceMinor, an integer number of kuruş (cents for EUR).
// Money as an integer number of minor units (kuruş for TRY, cent for EUR): no binary fractions
export type Currency = "TRY" | "EUR";
export class InvalidAmountError extends Error {
constructor(text: string) {
super(`invalid amount: ${text}`);
this.name = "InvalidAmountError";
}
}
// "349,90", "349.9" or "349" -> 34990; parsed as text, never through a binary float
export function parseMinorUnits(text: string): number {
const match = /^(\d{1,9})(?:[.,](\d{1,2}))?$/.exec(text.trim());
if (!match) {
throw new InvalidAmountError(text);
}
return Number(match[1]) * 100 + Number((match[2] ?? "0").padEnd(2, "0"));
}
// 34990 -> 349.9 for JSON; dividing an integer by 100 gives the nearest double, as decimal(10,2) would
export function toAmount(minorUnits: number): number {
return minorUnits / 100;
}
// 34990 -> "349.90" for CSV and logs
export function formatAmount(minorUnits: number): string {
const sign = minorUnits < 0 ? "-" : "";
const abs = Math.abs(minorUnits);
return `${sign}${Math.trunc(abs / 100)}.${String(abs % 100).padStart(2, "0")}`;
}
The rule: calculations always use integers, and decimal values are produced only at the boundary. src/db/books.json now holds "priceMinor": 34990; the CSV import from 08 converts "349,90" with parseMinorUnits, not with Number("349.90"). In the JSON sent to the client the amount is still a number (349.9): the BookSummary contract used by the Angular client has not changed. Dividing an integer by 100 gives the double closest to that decimal value; it carries no accumulated error.
How big is the difference? The price report from 06 summed VAT-inclusive revenue over 20 million lines with floats. I summed the same lines both ways:
float sum (post 06): 19707600000.05
integer sum (post 12): 19707600000.00
The float sum drifted by 5 kuruş; the integer sum is right (I also checked the result with Python's decimal module). The report itself now calculates in kuruş too. For rates such as VAT, rounding is done explicitly on each line: Math.round(quantity * unitPriceMinor * 110 / 100). Wherever there is a division, you choose the rounding rule; it is worth agreeing with the accounting team on which rule applies. In the database these values are stored as decimal(10,2); in 13 we convert them to kuruş when reading from SQL Server.
Layers: Route → Handler → Service → Repository
In 11 the routers called services directly, and the services were single module-level instances. We split the structure into the same responsibilities as in the ASP.NET Core Web API series post on layered architecture:
src/routes: only which path goes to which handler.src/handlers: the translator between HTTP and the domain. It validates the request against a schema, calls the service and maps the result to a DTO. No business rules.src/services: business rules. Taking the price from the catalog, rejecting an unknown ISBN, publishingorderPlaced.src/repositories: data access. In memory for now, SQL Server in 13.
The repository interface is async, because the SQL Server implementation will use the same interface:
// Async on purpose: the SQL Server implementation in post 13 keeps the same interface
export interface BookRepository {
list(query: BookQuery): Promise<{ items: Book[]; totalCount: number }>;
findByIsbn(isbn: string): Promise<Book | undefined>;
findManyByIsbn(isbns: readonly string[]): Promise<Map<string, Book>>;
}
Node has no built-in DI container, and a small API does not need one. Services take their dependencies through the constructor, and we wire everything together in one place, the composition root. It is the hand-written counterpart of the builder.Services.Add... part of ASP.NET Core's Program.cs:
// Composition root: the one place that decides which implementations are used
export function createServices(): AppServices {
const bookRepository = new InMemoryBookRepository(books as Book[]);
const orderService = new OrderService(bookRepository, new InMemoryOrderRepository());
registerOrderNotifications(orderService);
return { books: new BookService(bookRepository), orders: orderService };
}
createApp(services) takes these objects and passes them to the routers. In 16 the tests will hand the same createApp services built with fake repositories. In constructors we declare fields on a separate line (readonly #books: BookRepository;), because parameter properties do not work with type stripping (05). OrderService's order creation method is now async and always takes the price from the catalog:
async placeOrder(customerId: string, lines: readonly OrderLine[]): Promise<Order> {
const books = await this.#books.findManyByIsbn(lines.map((l) => l.isbn));
const unknown = lines.filter((l) => !books.has(l.isbn)).map((l) => l.isbn);
if (unknown.length > 0) {
throw new UnknownBookError(unknown);
}
const currencies = new Set(lines.map((l) => books.get(l.isbn)!.currency));
if (currencies.size > 1) {
throw new MixedCurrencyError();
}
// The price is taken from the catalog, never from the request
const items: OrderItem[] = lines.map((l) => ({ isbn: l.isbn, quantity: l.quantity, unitPriceMinor: books.get(l.isbn)!.priceMinor }));
const order: Order = {
id: await this.#orders.nextId(),
customerId,
status: "Pending",
currency: [...currencies][0],
items,
createdAtUtc: new Date().toISOString(),
};
await this.#orders.add(order);
log("info", "order placed", { orderId: order.id, items: items.length });
this.emit("orderPlaced", order);
return order;
}
Domain errors (UnknownBookError, BookNotFoundError…) know nothing about HTTP; turning them into status codes is the error middleware's job.
Request Schemas with zod
In 10 and 11 we wrote parsePlaceOrder by hand: 25 lines of typeof checks plus a separate TypeScript type. With zod we write the schema once and derive the type from it:
import { z } from "zod";
import { isbnSchema } from "#schemas/books";
const orderLine = z.object({
isbn: isbnSchema,
quantity: z.int().min(1).max(20),
});
// The request contract for POST /api/orders; unknown fields are rejected
export const placeOrderBody = z.strictObject({
customerId: z.string().trim().min(1).max(64),
lines: z
.array(orderLine)
.min(1)
.max(50)
.refine((lines) => new Set(lines.map((l) => l.isbn)).size === lines.length, { message: "each ISBN may appear only once" }),
});
// The TypeScript type is derived from the schema, so the two cannot drift apart
export type PlaceOrderBody = z.infer<typeof placeOrderBody>;
// The regex ends up in JSON Schema / OpenAPI; the checksum refinement only runs in code
export const isbnSchema = z
.string()
// abort: when the format is wrong, skip the checksum and report one error, not two
.regex(/^97[89]\d{10}$/, { message: "must be 13 digits starting with 978 or 979", abort: true })
.refine(isValidIsbn13, { message: "must be a valid ISBN-13" });
Thanks to z.infer, the type of body.lines[0].quantity in the handler is number; add a field to the schema and the type changes with it. strictObject rejects unknown fields: a client cannot send a field such as discount and hope it will be used somewhere. The ASP.NET Core counterpart is Data Annotations or FluentValidation (the Web API series post on model validation); the difference is that there the type already comes from the class, while here the type is derived from the schema. A single helper turns zod's errors into ASP.NET Core's errors dictionary:
// ["lines", 0, "quantity"] -> "lines[0].quantity", the shape ASP.NET Core uses in "errors"
function fieldName(path: readonly PropertyKey[]): string {
return path.reduce<string>((name, key) => (typeof key === "number" ? `${name}[${key}]` : name ? `${name}.${String(key)}` : String(key)), "") || "$";
}
export function parseRequest<T extends z.ZodType>(schema: T, value: unknown): z.infer<T> {
const result = schema.safeParse(value);
if (result.success) {
return result.data;
}
const errors: Record<string, string[]> = {};
for (const issue of result.error.issues) {
(errors[fieldName(issue.path)] ??= []).push(issue.message);
}
throw new RequestValidationError(errors);
}
The handler gets shorter and only does the translator's job:
// POST /api/orders: validate, call the service, map the result; no business rules here
export function placeOrder(orders: OrderService): RequestHandler {
return async (req, res) => {
if (!req.is("application/json")) {
throw new HttpError(415, "content-type must be application/json");
}
const body = parseRequest(placeOrderBody, req.body);
const order = await orders.placeOrder(body.customerId, body.lines);
res.status(201).location(`/api/orders/${order.id}`).json(toOrderDto(order));
};
}
The same approach works for query strings: everything in req.query is a string, so z.coerce.number() converts to a number first and then applies the rules; default fills in missing parameters.
// Query strings are text: z.coerce turns "2" into 2 before the number rules run
export const listBooksQuery = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(50).default(10),
q: z.string().trim().min(2).max(100).optional(),
author: z.string().trim().min(1).max(100).optional(),
sort: z.enum(["title", "price", "-price"]).default("title"),
});
Error Handling and Problem Details
RFC 9457 defines the type, title, status, detail and instance fields of an error response and the application/problem+json content type. ASP.NET Core's ProblemDetails uses this format; it adds an errors dictionary to validation errors and a traceId to every response. If the Node side returns the same contract, the Angular client cannot tell which language the API is written in. The error middleware now turns every kind of error into Problem Details in one place:
// Domain error -> HTTP status, in one place
const DOMAIN_STATUS = new Map<Function, number>([
[BookNotFoundError, 404],
[OrderNotFoundError, 404],
[UnknownBookError, 422],
[MixedCurrencyError, 422],
]);
// Four parameters: that is how Express recognises an error handler
export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
const context = currentRequestContext();
if (context?.signal.aborted) {
log("warn", "client went away", { method: req.method, path: req.path });
return;
}
if (res.headersSent) {
// Too late for a status code; Express's default handler closes the connection
log("error", "error after the response started", { error: (err as Error).message });
return next(err);
}
const common = { instance: req.originalUrl, traceId: context?.requestId };
if (err instanceof RequestValidationError) {
return sendProblem(res, 400, { title: err.message, ...common, errors: err.errors });
}
if (err instanceof DomainError) {
const status = DOMAIN_STATUS.get(err.constructor) ?? 422;
return sendProblem(res, status, { detail: err.message, ...common });
}
if (err instanceof HttpError) {
return sendProblem(res, err.status, { detail: err.message, ...common });
}
// body-parser errors carry a status and a type; their messages are not shown to the client
if (err?.type === "entity.parse.failed") {
return sendProblem(res, 400, { detail: "request body is not valid JSON", ...common });
}
if (err?.type === "entity.too.large") {
// The rest of the body is still on its way: close instead of reading it all
res.setHeader("connection", "close");
return sendProblem(res, 413, { detail: `request body is larger than ${err.limit} bytes`, ...common });
}
// Anything else is a bug: full details in the log, none in the response
log("error", "unhandled error", { error: (err as Error).stack });
sendProblem(res, 500, common);
};
sendProblem picks the type URI and title ASP.NET Core uses for the status and sends it as application/problem+json; as traceId we use the correlation id from 07. Let's try it. First a valid order:
HTTP/1.1 201 Created
Location: /api/orders/ORD-1001
Content-Type: application/json; charset=utf-8
{"id":"ORD-1001","customerId":"ada","status":"Pending","createdAtUtc":"2026-11-14T19:15:50.122Z","items":[{"isbn":"9780441172719","quantity":3,"unitPrice":{"amount":349.9,"currency":"TRY"},"lineTotal":{"amount":1049.7,"currency":"TRY"}},{"isbn":"9780547928227","quantity":1,"unitPrice":{"amount":289.5,"currency":"TRY"},"lineTotal":{"amount":289.5,"currency":"TRY"}}],"total":{"amount":1339.2,"currency":"TRY"}}
The order from Guess First, total 1339.2. Now a body where everything is wrong: an empty customer, an ISBN whose checksum fails, an ISBN with dashes, a zero and a fractional quantity, and an unknown discount field:
curl -s -X POST http://127.0.0.1:3000/api/orders -H "content-type: application/json" -H "x-request-id: req-12b" \
-d '{"customerId":" ","lines":[{"isbn":"9780441172718","quantity":0},{"isbn":"978-0441172719","quantity":2.5}],"discount":50}'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"instance":"/api/orders","traceId":"req-12b","errors":{"customerId":["Too small: expected string to have >=1 characters"],"lines[0].isbn":["must be a valid ISBN-13"],"lines[0].quantity":["Too small: expected number to be >=1"],"lines[1].isbn":["must be 13 digits starting with 978 or 979"],"lines[1].quantity":["Invalid input: expected int, received number"],"$":["Unrecognized key: \"discount\""]}}
All six errors in a single response; customerId was rejected because it was empty after trimming whitespace. The ISBN with dashes got only the format error, because abort: true on the regex skips the checksum check; without it the same field got two messages. The other kinds of error:
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.21","title":"Unprocessable Content","status":422,"detail":"unknown ISBN: 9780306406157","instance":"/api/orders","traceId":"req-12c"}
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"detail":"request body is not valid JSON","instance":"/api/orders","traceId":"req-12d"}
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"instance":"/api/orders","traceId":"req-12i","errors":{"lines":["each ISBN may appear only once"]}}
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not Found","status":404,"detail":"book 9780306406157 was not found","instance":"/api/books/9780306406157","traceId":"req-12f"}
A valid ISBN that is not in the catalog got a 422: the request is well formed but cannot be processed. For broken JSON, the V8 message from 11 no longer goes to the client. Finally, an unexpected error: labs/12/bug-500.ts passes createApp a BookService that throws:
500 application/problem+json; charset=utf-8
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"An error occurred while processing your request.","status":500,"instance":"/api/books","traceId":"req-12z"}
{"time":"2026-11-14T19:15:24.240Z","level":"error","requestId":"req-12z","msg":"unhandled error","error":"TypeError: Cannot read properties of undefined (reading 'priceMinor')\n at Object.list (file:///home/user/bookstore-api/labs/12/bug-500.ts:8:15)\n at file:///home/user/bookstore-api/src/handlers/booksHandlers.ts:11:34\n ..."}
The client sees only a generic title and the traceId; the stack trace sits in the log under the same id. When a complaint reaches the support team, the traceId leads to the log line in one step.
Paging and Filtering
The list endpoint uses the envelope from the Web API series post on pagination, filtering and sorting: items, page, pageSize, totalCount, totalPages, hasNextPage. I grew the seed catalog to 12 books:
curl -s "http://127.0.0.1:3000/api/books?page=2&pageSize=5&sort=-price"
curl -s -H "x-request-id: req-12e" "http://127.0.0.1:3000/api/books?page=0&pageSize=500&sort=year"
{"items":[{"isbn":"9780547928227","title":"The Hobbit","price":{"amount":289.5,"currency":"TRY"}},{"isbn":"9780141439563","title":"Great Expectations","price":{"amount":279,"currency":"TRY"}},{"isbn":"9780141441146","title":"Jane Eyre","price":{"amount":265,"currency":"TRY"}},{"isbn":"9780141439587","title":"Emma","price":{"amount":245,"currency":"TRY"}},{"isbn":"9780141439556","title":"Wuthering Heights","price":{"amount":239,"currency":"TRY"}}],"page":2,"pageSize":5,"totalCount":12,"totalPages":3,"hasNextPage":true}
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"instance":"/api/books?page=0&pageSize=500&sort=year","traceId":"req-12e","errors":{"page":["Too small: expected number to be >=1"],"pageSize":["Too big: expected number to be <=50"],"sort":["Invalid option: expected one of \"title\"|\"price\"|\"-price\""]}}
The upper limit on pageSize matters: an unbounded list opens the door to the large JSON.stringify cost we saw in 06 and to an easy denial of service. Making sort an enum also means that when we move to SQL in 13, we never build a column name from user input.
OpenAPI and Versioning
zod 4 can turn a schema into JSON Schema, the raw material of an OpenAPI document:
import { z } from "zod";
import { placeOrderBody } from "#schemas/orders";
// zod 4 can export a schema as JSON Schema, the raw material for an OpenAPI document
console.log(JSON.stringify(z.toJSONSchema(placeOrderBody), null, 2));
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"customerId": {
"type": "string",
"minLength": 1,
"maxLength": 64
},
"lines": {
"minItems": 1,
"maxItems": 50,
"type": "array",
"items": {
"type": "object",
"properties": {
"isbn": {
"type": "string",
"pattern": "^97[89]\\d{10}$"
},
"quantity": {
"type": "integer",
"minimum": 1,
"maximum": 20
}
},
"required": [
"isbn",
"quantity"
],
"additionalProperties": false
}
}
},
"required": [
"customerId",
"lines"
],
"additionalProperties": false
}
Note that the rules written with refine (the ISBN checksum, the duplicate ISBN check) do not show up in JSON Schema; only the regex, the limits and the additionalProperties: false from strictObject carry over. That is why we added the regex to the ISBN schema as well. There are libraries that generate a complete OpenAPI document from these schemas; we don't go into them in this series. The .NET side of Swagger/OpenAPI and API versioning strategies (URL, header, query string) are covered in the Web API series; in Express the simplest way is to mount a separate router, as in app.use("/api/v2/orders", ordersRouterV2).
In Production
Don't calculate money with floats. Use integer minor units and produce decimal values only at the boundary. For rate calculations, write the rounding rule explicitly and test it.
Don't take the price from the client. An order request carries only ISBNs and quantities; the price comes from the catalog on the server. Otherwise someone buys a book for 1 kuruş.
Don't put internal details in error responses. Stack traces, SQL messages and parser messages go to the log; the client gets only Problem Details and the traceId.
Put limits on every list endpoint. The page size limit, length limits on filter fields and the allowed sort fields belong in the schema.
Common Pitfalls
Passing an unvalidated req.body to the service. A TypeScript type checks nothing at runtime; always treat req.body as unknown.
Leaking stack traces to the client. Without an error middleware, Express's default page shows the stack trace in development mode (11).
Writing the schema and the type separately. One changes and the other is forgotten; derive the type from the schema with z.infer.
Forgetting that z.object silently drops extra fields. The default is to strip unknown fields; if you want to notice that a client sent a misspelled field, use z.strictObject.
"Fixing" 0.1 + 0.2 with toFixed(2). It fixes the display, not the error in the total.
A Small Experiment
★☆☆ Call parseMinorUnits with "1.234,50" (Turkish notation with a thousands separator). What happens? If you wanted to support it, how would you change the regex, and what new ambiguity would that create?
★★☆ Add a 10% discount rule to orders: the institutional discount for 20 or more items (Institutional Orders). Do you round the discount per line or on the total? Calculate both in kuruş and show the difference for 3 × 349.90.
★★★ Add minPrice and maxPrice query parameters to GET /api/books. The client sends amounts in lira ("199.90"); write a z.string().transform(...) in the schema that converts them to kuruş with parseMinorUnits, and return the case minPrice > maxPrice as a single error.
The shape of the API is now in place, but the data still lives in memory. In the next post we connect the repositories to BookstoreDb on SQL Server: the mssql package, the connection pool, parameterized queries, transactions and the conversion between decimal(10,2) and kuruş.