API Contracts & Validation
Learning Objectives
- You can distinguish a database row, a JSON representation, a schema, and a TypeScript type.
- You can validate JSON bodies and path parameters, use parsed outputs, and report known failures deliberately.
- You can reuse input constraints and distinguish them from decisions about stored state.
Reading a request body is not enough to know whether we can use it. A caller can omit a field, send the wrong type, or propose a value that the application does not allow. The caller also need not use the browser application that we wrote.
We can use a schema to check incoming requests at runtime and describe the accepted values. We use Zod to define these schemas.
Set up validation
The practice application’s API has Hono and postgres.js but no schema validator. In the practice application’s api/deno.json, replace the existing hono and hono/cors mappings with the following Hono entries and add the validator mappings. Preserve @src/, assertions, PostgreSQL, tasks, and other existing entries:
{ "hono": "npm:hono@4.13.5", "hono/": "npm:/hono@4.13.5/", "zod": "npm:zod@4.5.4", "@hono/standard-validator": "npm:@hono/standard-validator@0.4.0"}The hono/ prefix includes subpaths such as hono/cors. Remove the old exact
hono/cors entry so all Hono imports select the same distribution. These are the
course’s pinned versions.
A schema has an input and an output
Let’s build a small endpoint for room proposals. A proposal asks for a room name and a capacity.
We will describe a room request with a schema. For example, { name: " Quiet room ", capacity: 4 } will become { name: "Quiet room", capacity: 4, hasProjector: false } after validation. The schema checks the input, normalizes the name, and supplies a default.
Create api/src/schemas/room-schema.ts. From this chapter onward, put named
application schemas under src/schemas, grouped by resource or coherent concern.
A file may export several related schemas. These modules are independent of
Hono, database access, and environment reads.
Create the folder and file, and place the following into it:
import { z } from "zod";
export const RoomProposalSchema = z.object({ name: z.string().trim().min(1).max(80), capacity: z.number().int().min(1).max(500), hasProjector: z.boolean().default(false),}).strict();The input may omit hasProjector. The parsed output always contains a Boolean. name is trimmed before its length is checked. The JSON string "4" is not accepted as the number 4, and unknown fields are rejected. These are choices made by this API, not universal rules for every application.
Zod also lets TypeScript infer the shape of successfully parsed values from the schema. We do not need a separate type declaration to use those values. We still have to call the parser: TypeScript annotations do not validate a request at runtime. Zod’s basics describe the parsing API.
Inspect validation directly
The following call returns either parsed data or validation issues:
const result = RoomProposalSchema.safeParse({ name: " Quiet room ", capacity: 4 });if (result.success) { console.log(result.data); // includes hasProjector: false} else { console.log(result.error.issues);}success distinguishes the two results. Use the parsed output so that later code receives the normalized values and defaults.
Predict the parsed request
0 / 5 points
A strict schema trims purpose, accepts integer quantity, and defaults omitted urgent to false. The input explicitly includes {purpose: " Workshop ", quantity: 2, urgent: true}. Which value should the success handler return as the accepted data?
Four related shapes
Suppose a database row stores a room’s internal ID, name, capacity, creator ID, and moderation note. A public JSON response should not return that row merely because a database library assigned it a type.
| Shape | Question it answers |
|---|---|
| Database row | What do we store and constrain? |
| HTTP representation | What values and field names do we send over the network? |
| Validation schema | Which caller-proposed values do we accept and normalize? |
| TypeScript type | What can the checker establish about code using these values? |
For this example, the accepted response contains only the normalized proposal. A timestamp in a database can become an ISO string in JSON; it does not arrive in the browser as a JavaScript Date automatically. Choose the wire representation explicitly.
In this part, apply runtime schemas to incoming values and construct public responses deliberately. A response can also be checked against an output schema, for example to catch a mismatch between an implementation and its documented contract. We do not require an additional runtime parse of every outgoing response here. Continue selecting public fields explicitly and testing response shapes. A schema’s parsed output, used above, is the result of input validation; it is not a separate check of an outgoing HTTP response.
Connect validation to the HTTP route
The adapter sValidator connects a schema to Hono. It checks the request before
the handler runs and stores the parsed output for c.req.valid(...).
First follow the successful request: the validator parses the body and the
handler reads its normalized output. Create
api/src/routes/room-proposal-routes.ts:
import { Hono } from "hono";import { sValidator } from "@hono/standard-validator";import { RoomProposalSchema } from "@src/schemas/room-schema.ts";
export const app = new Hono();
const validateProposal = sValidator("json", RoomProposalSchema);
app.post( "/api/room-proposals", validateProposal, (c) => { const proposal = c.req.valid("json"); return c.json({ accepted: proposal }, 200); },);The handler returns 200 because it checks a proposal without saving a resource.
The adapter already rejects schema-invalid input. Before customizing its errors,
check that a valid request reaches the handler. Create
api/tests/room-proposal-routes_test.ts:
import { assertEquals } from "@std/assert";import { app } from "@src/routes/room-proposal-routes.ts";
Deno.test("a valid proposal uses normalized values", async () => { const response = await app.request("/api/room-proposals", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: " Quiet room ", capacity: 4 }), }); assertEquals(response.status, 200); assertEquals(await response.json(), { accepted: { name: "Quiet room", capacity: 4, hasProjector: false }, });});Run this test from practice/:
docker compose run --build --rm --no-deps api deno test --cached-only tests/room-proposal-routes_test.tsGive validation errors a stable shape
The adapter’s default error response is enough to reject a request. Our API can also
choose a stable error code and message. Replace the validateProposal
declaration with this version; keep the route unchanged:
const validateProposal = sValidator("json", RoomProposalSchema, (result, c) => { if (result.success) { return; }
return c.json({ error: { code: "INVALID_INPUT" as const, message: "The room proposal is invalid", }, }, 400);});Malformed JSON is rejected before the schema can inspect values. The adapter’s normal rejection is sufficient for this first route. A complete API can give that failure its own stable code through the supplied error-handler reference below.
Stable malformed-JSON error response
Add this supplied adapter to the route module if callers need to distinguish
INVALID_JSON from INVALID_INPUT:
import { HTTPException } from "hono/http-exception";
app.onError((error, c) => { if (error instanceof HTTPException && error.status === 400) { return c.json({ error: { code: "INVALID_JSON", message: "Malformed JSON" } }, 400); }
console.error(error); return c.json({ error: { code: "INTERNAL_ERROR", message: "Unexpected error" } }, 500);});Follow validation before the handler
0 / 5 points
A POST route runs a JSON validator before its handler. The validator returns 400 INVALID_INPUT for {capacity: -1}. The handler would increment a counter and return 200. Which observation should a test make?
Test ordinary and arbitrary callers
Keep the successful-request test. Add these tests to the same file, adding only the new schema import at the top:
import { RoomProposalSchema } from "@src/schemas/room-schema.ts";
Deno.test("normalization supplies the default", () => { assertEquals(RoomProposalSchema.parse({ name: " Quiet room ", capacity: 4 }), { name: "Quiet room", capacity: 4, hasProjector: false, });});
Deno.test("a non-TypeScript caller cannot bypass validation", async () => { const response = await app.request("/api/room-proposals", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Room", capacity: -1, approved: true }), }); assertEquals(response.status, 400); assertEquals((await response.json()).error.code, "INVALID_INPUT");});Add cases for a blank name, a fractional capacity, a string capacity, a missing field, malformed JSON, and a valid proposal with an explicit projector value. Assert the stable error code, not every word of a library’s generated message.
Run the validation tests from practice/:
docker compose run --build --rm --no-deps api deno test --cached-only tests/room-proposal-routes_test.tsTo try HTTP requests through the listening server, add the following to the main api/src/app.ts, alongside the room mount:
import { app as proposalApp } from "@src/routes/room-proposal-routes.ts";
app.route("/", proposalApp);Save two files in practice/: proposal-valid.json containing {"name":" Quiet room ","capacity":4}, and proposal-invalid.json containing {"name":"Room","capacity":-1}. With the services running, send each file:
curl -i -H "Content-Type: application/json" --data-binary @proposal-valid.json http://localhost:8000/api/room-proposalscurl -i -H "Content-Type: application/json" --data-binary @proposal-invalid.json http://localhost:8000/api/room-proposalsExpect 200 with the trimmed name and default hasProjector: false, then 400 with INVALID_INPUT. These files avoid shell-specific JSON quoting; in native PowerShell use curl.exe.
Validate a relationship within a value
Individual field checks are not always sufficient. Suppose a reading group
accepts one to three distinct weekday names. The array length and element type
do not establish distinctness. This isolated syntax illustration is not another
application module; if used by a route or service, the schema would belong in
src/schemas/meeting-schema.ts:
const MeetingDaysSchema = z .array(z.enum(["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"])) .min(1) .max(3) .refine((days) => new Set(days).size === days.length, { message: "Choose different weekdays", });refine adds a check over the parsed value. This check depends only on the
submitted days, not on a database reservation that another request can change.
The latter would remain a business rule evaluated against current server state.
Separate input rules from current state
0 / 5 points
A schema accepts an integer seat count from 1 through 10. A request for six seats parses successfully. Before the handler queries the room, another request reduces its availability from eight seats to four. What should the server decide from the new observation?
Validate path parameters with the same approach
From here onward, prefer Zod schemas for input validation and sValidator at HTTP
boundaries. The validation target identifies where the input comes from: json
for a body, param for path parameters, and query for query parameters. Handlers
read the parsed result with the corresponding c.req.valid(...) call.
Return to Chapter 3.2’s room detail route. The URL supplies a string, even though
the repository will need a numeric ID. Create api/src/schemas/id-schema.ts:
import { z } from "zod";
export const PositiveIdSchema = z.string() .regex(/^[1-9]\d*$/) .transform(Number) .pipe(z.number().int().min(1).max(2147483647));
export const IdParamsSchema = z.object({ id: PositiveIdSchema });This reusable helper accepts complete positive decimal IDs in the PostgreSQL
INTEGER range. It converts a URL string into a number. For example, "12"
becomes 12, while "12chairs" is rejected. A lookup still decides whether ID 12
exists; malformed input and a missing resource are different outcomes.
Save the schema above in the practice application. Keep the short failure response beside the route that uses it.
In api/src/routes/room-routes.ts, add the imports below at the top, declare
validateRoomId, and replace the existing detail route with this version. Keep the
room array, logging middleware, collection route, and not-found handler:
import { sValidator } from "@hono/standard-validator";import { IdParamsSchema } from "@src/schemas/id-schema.ts";const validateRoomId = sValidator("param", IdParamsSchema, (result, c) => { if (!result.success) { return c.json({ error: { code: "INVALID_ID", message: "Invalid room ID" } }, 400); }});
app.get("/api/rooms/:id", validateRoomId, (c) => { const { id } = c.req.valid("param"); const room = rooms.find((candidate) => candidate.id === id); if (!room) { return c.json({ error: { code: "NOT_FOUND", message: "Room not found" } }, 404); }
return c.json(room);});The handler now receives a number from c.req.valid("param"); it does not parse
or validate the original string again. The existing success and missing-room tests still apply.
Add cases for the new invalid-ID response:
Deno.test("room IDs use the complete positive-integer syntax", async () => { for (const id of ["12chairs", "0", "-1", "01", "1e2", "1.5", "2147483648"]) { const response = await app.request(`/api/rooms/${id}`); assertEquals(response.status, 400); assertEquals((await response.json()).error.code, "INVALID_ID"); }});Run both files from practice/:
docker compose run --build --rm --no-deps api deno test --cached-only tests/room-routes_test.ts tests/room-proposal-routes_test.tsValidate and normalize an equipment request
0 / 20 points
Files in the editor
The editor below contains all 2 files for this exercise, although only one
opens initially. Use the Files panel to expand api/src/ and its folders,
then select a filename to open it. If the panel is collapsed, choose
Show file tree in the editor toolbar.
Edit:
api/src/schemas/equipment-schema.tsapi/src/routes/equipment-request-routes.ts
Task
Complete api/src/schemas/equipment-schema.ts and api/src/routes/equipment-request-routes.ts. The grader
supplies imports hono (Hono 4.13.5), zod (Zod 4.5.4), and
@hono/standard-validator (0.4.0); @src/ maps to api/src/.
Use the imports already present in the starter. Keep the supplied app.onError
adapter for malformed JSON and unexpected errors; implementing that formatter
is not part of this task. Add the schema-failure hook to sValidator and
complete the normalized success response.
Requirements
Export EquipmentRequestSchema with exactly these allowed inputs:
| Field | Accepted values |
|---|---|
purpose | string, trimmed to 1-120 characters |
quantity | JSON integer from 1 through 8, not a numeric string |
codes | 1-3 distinct strings, each exactly two uppercase letters then two digits |
urgent | Boolean; omission produces false in the parsed output |
Reject unknown fields. Use a collection refinement for distinct codes.
Create and export the Hono instance as app. Register the route with its validation middleware.
POST /api/equipment-requests uses sValidator, reads the normalized value from
c.req.valid('json'), and returns { accepted: <parsed value> } with 200.
It validates a proposal; it does not claim to save a resource. Invalid shape or
missing/unsupported content type gives 400 INVALID_INPUT. Malformed JSON with
JSON content type gives 400 INVALID_JSON. Both return JSON { error: { code, message } }, where message is a
nonempty explanation; its exact wording is not assessed. Exporting app must not
start a listener. For example, {purpose:" Demo ",quantity:1,codes:["AA01"]}
is accepted as {accepted:{purpose:"Demo",quantity:1,codes:["AA01"],urgent:false}}.
Check and submit
Choose Submit solution below the editor to run the grading checks and read their feedback. All 2 editor files are submitted together, including supporting files and files that are not open as tabs. This editor has no Run control; you can submit for grading without setting up a local application.
Four checks award 5 points each: normalization/defaults, collection/field boundaries, raw request behavior, and content-type/malformed JSON handling. The API being implemented needs neither persistence nor user-authentication logic. The grader sends arbitrary JSON, not just TypeScript-checked values.
Check Your Understanding
- How can schema input and output differ?
- Why is a database row not automatically the right public response?
- What does Hono’s
sValidatormiddleware do with an incoming value and its schema at runtime? - An invalid path and malformed body arrive together. How does validator order decide the reported error?
- Which failures remain possible after a request passes both type checking and schema validation?