Server-Side Applications & Databases

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?

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.

ShapeQuestion it answers
Database rowWhat do we store and constrain?
HTTP representationWhat values and field names do we send over the network?
Validation schemaWhich caller-proposed values do we accept and normalize?
TypeScript typeWhat 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/:

Terminal window
docker compose run --build --rm --no-deps api deno test --cached-only tests/room-proposal-routes_test.ts

Give 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/:

Terminal window
docker compose run --build --rm --no-deps api deno test --cached-only tests/room-proposal-routes_test.ts

To 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:

Terminal window
curl -i -H "Content-Type: application/json" --data-binary @proposal-valid.json http://localhost:8000/api/room-proposals
curl -i -H "Content-Type: application/json" --data-binary @proposal-invalid.json http://localhost:8000/api/room-proposals

Expect 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/:

Terminal window
docker compose run --build --rm --no-deps api deno test --cached-only tests/room-routes_test.ts tests/room-proposal-routes_test.ts

Validate 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.ts
  • api/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:

FieldAccepted values
purposestring, trimmed to 1-120 characters
quantityJSON integer from 1 through 8, not a numeric string
codes1-3 distinct strings, each exactly two uppercase letters then two digits
urgentBoolean; 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

  1. How can schema input and output differ?
  2. Why is a database row not automatically the right public response?
  3. What does Hono’s sValidator middleware do with an incoming value and its schema at runtime?
  4. An invalid path and malformed body arrive together. How does validator order decide the reported error?
  5. Which failures remain possible after a request passes both type checking and schema validation?