TypeScript fetch: Typed JSON Client with Zod, POST and AbortController Timeouts
November 6, 2026
Welcome to episode 38 of TypeScript from Zero, a series for complete beginners (no JavaScript needed). Welcome back to TypeScript from zero! We built a server. Now the other side: a program that calls an HTTP server. Fetch is built into Node, and into every browser. Today: a typed client for our task server. A new file called client.ts. 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
touch src/client.ts
code .1. fetch
The server runs in the left pane, as before. In the client, fetch takes the address, and gives back a promise of a response. Await it, and then await the body, parsed as JSON. We print the status code, and the number of tasks. We made a mistake in the address, and the server answers four oh four. But the program prints undefined, and tsc is quiet. The reason: res.json returns any, like JSON parse in episode twenty nine. The answer of a server is outside data, and any switches the checks off.
src/client.ts:
import { z } from "zod";
import { TaskSchema } from "./schema.ts";
const BASE = "http://localhost:3000";
const res = await fetch(BASE + "/nothing");
const data = await res.json();
console.log(res.status, data.length);$ node src/client.ts
404 undefined
Listening on http://localhost:30002. Zod
We know the cure: a schema. We fix the address, and z dot array of the task schema parses the answer. Now the data has the type, a list of tasks. And with the wrong address, Zod would have stopped us at once. Run it, and we get the status two hundred, and the count.
src/client.ts:
import { z } from "zod";
import { TaskSchema } from "./schema.ts";
const BASE = "http://localhost:3000";
const res = await fetch(BASE + "/tasks");
const data = z.array(TaskSchema).parse(await res.json());
console.log(res.status, data.length);$ node src/client.ts
200 0
Listening on http://localhost:30003. POST
Sending data is the same call with options: the method post, a header that says JSON, and the body as text from JSON stringify. The server answers two oh one, and we parse the new task with the same schema.
src/client.ts:
import { z } from "zod";
import { TaskSchema } from "./schema.ts";
const BASE = "http://localhost:3000";
const res = await fetch(BASE + "/tasks");
const data = z.array(TaskSchema).parse(await res.json());
console.log(res.status, data.length);
const created = await fetch(BASE + "/tasks", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title: "From the client" }),
});
console.log(created.status, TaskSchema.parse(await created.json()));$ node src/client.ts
200 0
201 { id: 1, title: 'From the client', status: 'todo' }
Listening on http://localhost:30004. Timeout
A client should not wait forever. An abort controller gives us a signal that we pass to fetch. When we call abort, fetch stops, with an error called abort error. Here the timer is one millisecond, much too short, so we can see it work.
src/client.ts:
import { z } from "zod";
import { TaskSchema } from "./schema.ts";
const BASE = "http://localhost:3000";
const res = await fetch(BASE + "/tasks");
const data = z.array(TaskSchema).parse(await res.json());
console.log(res.status, data.length);
const created = await fetch(BASE + "/tasks", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title: "From the client" }),
});
console.log(created.status, TaskSchema.parse(await created.json()));
const controller = new AbortController();
setTimeout(() => controller.abort(), 1);
try {
await fetch(BASE + "/tasks", { signal: controller.signal });
} catch (err) {
console.log(err instanceof Error ? err.name : err);
}$ node src/client.ts
200 1
201 { id: 2, title: 'From the client', status: 'todo' }
AbortError
Listening on http://localhost:30005. No server
And when the server is not there at all? We stop it. Now the client crashes: fetch failed, and inside, the reason: connection refused. Every client needs a plan for this case, and the try and catch from episode thirty is the plan.
$ node src/client.ts
node:internal/modules/run_main:111
triggerUncaughtException(
^
[TypeError: fetch failed] {
[cause]: Error: connect ECONNREFUSED 127.0.0.1:3000
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:2083:16) {
errno: -111,
code: 'ECONNREFUSED',
syscall: 'connect',
address: '127.0.0.1',
port: 3000
}
}
Node.js v26.13.0Recap
Today: fetch and await, the answer as unknown, checked with Zod, a post request with a body, and a timeout with an abort controller. Next episode: events, with a typed event emitter.