TypeScript API: Validate Request Bodies with Zod and Return 422 with Field Errors
November 6, 2026
Welcome to episode 37 of TypeScript from Zero, a series for complete beginners (no JavaScript needed). Welcome back to TypeScript from zero! Our server can only read. Today it learns to listen: a post request creates a task. And data from outside is never to be trusted, so we check it with Zod, and answer with the right status code. We start in the schema file. Every code block below is the exact code and output from the video, ready to copy.
- All episodes are in the TypeScript from Zero playlist.
- Versions used: Node.js 26.13.0, npm 11.20.0, TypeScript 7.0.2.
Setup
We start in the terminal and open the project in VS Code:
cd tasks
code .1. Schema
First a schema for the data that a client may send. A new task needs only a title, a text that is not empty, with our own message for the empty case. The id and the status are not up to the client: the server decides those.
2. Helpers
In the server file we update the imports. And a helper, read body, collects the pieces of the request, the chunks, into one text. For await waits for each chunk.
3. POST route
Now the new route. Step one: the body must be JSON, and if parsing fails, the answer is four hundred, bad request. The result goes into a variable of type unknown. Step two: safe parse with our schema. If it fails, the answer is four twenty two, unprocessable content, with the problems per field, from z dot flatten error. Step three: a valid title makes a new todo task, saves it, and answers two oh one, created.
4. Run
The server restarts by itself. We post a good task: two oh one, and the new task comes back. Now an empty title: four twenty two, and the answer says exactly which field is wrong, and why. And a body that is not JSON at all: four hundred. In the list, our new task is there.
src/schema.ts:
import { z } from "zod";
export const TaskSchema = z.discriminatedUnion("status", [
z.object({ id: z.number(), title: z.string(), status: z.literal("todo") }),
z.object({ id: z.number(), title: z.string(), status: z.literal("doing"), startedAt: z.string() }),
z.object({ id: z.number(), title: z.string(), status: z.literal("done"), finishedAt: z.string() }),
]);
export type Task = z.infer<typeof TaskSchema>;
export const NewTaskSchema = z.object({
title: z.string().min(1, "title must not be empty"),
});src/server.ts:
import { createServer } from "node:http";
import type { IncomingMessage, ServerResponse } from "node:http";
import { config } from "./config.ts";
import { loadTasks, saveTasks } from "./store.ts";
import { z } from "zod";
import { NewTaskSchema } from "./schema.ts";
import type { Task } from "./task.ts";
function sendJson(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { "content-type": "application/json" });
res.end(JSON.stringify(body));
}
async function readBody(req: IncomingMessage): Promise<string> {
let text = "";
for await (const chunk of req) {
text += chunk;
}
return text;
}
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://localhost");
const tasks = await loadTasks();
if (req.method === "GET" && url.pathname === "/tasks") {
sendJson(res, 200, tasks);
return;
}
if (req.method === "POST" && url.pathname === "/tasks") {
let data: unknown;
try {
data = JSON.parse(await readBody(req));
} catch {
sendJson(res, 400, { error: "body is not valid JSON" });
return;
}
const result = NewTaskSchema.safeParse(data);
if (!result.success) {
sendJson(res, 422, z.flattenError(result.error));
return;
}
const task: Task = { id: tasks.length + 1, title: result.data.title, status: "todo" };
await saveTasks([...tasks, task]);
sendJson(res, 201, task);
return;
}
const match = url.pathname.match(/^\/tasks\/(\d+)$/);
if (req.method === "GET" && match) {
const id = Number(match[1]);
const task = tasks.find((t) => t.id === id);
if (task) {
sendJson(res, 200, task);
} else {
sendJson(res, 404, { error: "no such task" });
}
return;
}
sendJson(res, 404, { error: "not found" });
});
server.listen(config.PORT, () => {
console.log("Listening on http://localhost:" + config.PORT);
});$ node src/client.ts
HTTP/1.1 201 Created
content-type: application/json
Date: Fri, 06 Nov 2026 10:06:11 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
{"id":1,"title":"Learn Zod","status":"todo"}
HTTP/1.1 422 Unprocessable Entity
content-type: application/json
Date: Fri, 06 Nov 2026 10:06:11 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
{"formErrors":[],"fieldErrors":{"title":["title must not be empty"]}}
HTTP/1.1 400 Bad Request
content-type: application/json
Date: Fri, 06 Nov 2026 10:06:11 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
{"error":"body is not valid JSON"}
[{"id":1,"title":"Learn Zod","status":"todo"}]
Listening on http://localhost:30005. Error
Why the schema, and the unknown type? Try to read the title straight from the data. tsc says: data is of type unknown. TS18046. Nobody can use data from outside before it has been checked. That is the whole idea.
src/server.ts:
import { createServer } from "node:http";
import type { IncomingMessage, ServerResponse } from "node:http";
import { config } from "./config.ts";
import { loadTasks, saveTasks } from "./store.ts";
import { z } from "zod";
import { NewTaskSchema } from "./schema.ts";
import type { Task } from "./task.ts";
function sendJson(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { "content-type": "application/json" });
res.end(JSON.stringify(body));
}
async function readBody(req: IncomingMessage): Promise<string> {
let text = "";
for await (const chunk of req) {
text += chunk;
}
return text;
}
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://localhost");
const tasks = await loadTasks();
if (req.method === "GET" && url.pathname === "/tasks") {
sendJson(res, 200, tasks);
return;
}
if (req.method === "POST" && url.pathname === "/tasks") {
let data: unknown;
try {
data = JSON.parse(await readBody(req));
} catch {
sendJson(res, 400, { error: "body is not valid JSON" });
return;
}
const result = NewTaskSchema.safeParse(data);
console.log(data.title);
if (!result.success) {
sendJson(res, 422, z.flattenError(result.error));
return;
}
const task: Task = { id: tasks.length + 1, title: result.data.title, status: "todo" };
await saveTasks([...tasks, task]);
sendJson(res, 201, task);
return;
}
const match = url.pathname.match(/^\/tasks\/(\d+)$/);
if (req.method === "GET" && match) {
const id = Number(match[1]);
const task = tasks.find((t) => t.id === id);
if (task) {
sendJson(res, 200, task);
} else {
sendJson(res, 404, { error: "no such task" });
}
return;
}
sendJson(res, 404, { error: "not found" });
});
server.listen(config.PORT, () => {
console.log("Listening on http://localhost:" + config.PORT);
});$ npx tsc --noEmit
src/server.ts:38:17 - error TS18046: 'data' is of type 'unknown'.
38 console.log(data.title);
~~~~
Found 1 error in src/server.ts:386. Fix
We delete the line, and tsc is quiet again.
src/server.ts:
import { createServer } from "node:http";
import type { IncomingMessage, ServerResponse } from "node:http";
import { config } from "./config.ts";
import { loadTasks, saveTasks } from "./store.ts";
import { z } from "zod";
import { NewTaskSchema } from "./schema.ts";
import type { Task } from "./task.ts";
function sendJson(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { "content-type": "application/json" });
res.end(JSON.stringify(body));
}
async function readBody(req: IncomingMessage): Promise<string> {
let text = "";
for await (const chunk of req) {
text += chunk;
}
return text;
}
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://localhost");
const tasks = await loadTasks();
if (req.method === "GET" && url.pathname === "/tasks") {
sendJson(res, 200, tasks);
return;
}
if (req.method === "POST" && url.pathname === "/tasks") {
let data: unknown;
try {
data = JSON.parse(await readBody(req));
} catch {
sendJson(res, 400, { error: "body is not valid JSON" });
return;
}
const result = NewTaskSchema.safeParse(data);
if (!result.success) {
sendJson(res, 422, z.flattenError(result.error));
return;
}
const task: Task = { id: tasks.length + 1, title: result.data.title, status: "todo" };
await saveTasks([...tasks, task]);
sendJson(res, 201, task);
return;
}
const match = url.pathname.match(/^\/tasks\/(\d+)$/);
if (req.method === "GET" && match) {
const id = Number(match[1]);
const task = tasks.find((t) => t.id === id);
if (task) {
sendJson(res, 200, task);
} else {
sendJson(res, 404, { error: "no such task" });
}
return;
}
sendJson(res, 404, { error: "not found" });
});
server.listen(config.PORT, () => {
console.log("Listening on http://localhost:" + config.PORT);
});$ npx tsc --noEmitRecap
Today: a post route, reading the body, four hundred for broken JSON, four twenty two with field errors from Zod, two oh one for a created task, and why outside data starts as unknown. Next episode: the other side, calling an HTTP server from TypeScript with fetch.