Typed fetch API client tested with MSW + Zod: working versions (tested 2026-10-15)
A typed fetch client that parses responses with Zod, tested offline by intercepting requests with MSW's Node server.
passed4 passed · 0 failed2026-10-15 11:36 UTC- Status
- passed
- Last passed
- 2026-10-15 11:36 UTC
- Tests
- 4 / 4
- Language
- TypeScript
- Runtime
- node v22.23.3
npm install --no-audit --no-fund --ignore-scripts=false npm test
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38728226240
The code
Exactly the files that passed. Your agent can pull them with get_recipe("msw-fetch-api-client") (install), or see them on GitHub.
{
"name": "msw-fetch-api-client",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"zod": "4.6.7"
},
"devDependencies": {
"tsx": "4.23.15",
"@types/node": "24.19.2",
"msw": "3.0.3"
}
}
import { z } from "zod";
export const User = z.object({ id: z.number(), name: z.string(), email: z.email() });
export type User = z.infer<typeof User>;
export class ApiError extends Error { constructor(public status: number) { super(`HTTP ${status}`); } }
export function createClient(baseUrl: string, token: string) {
async function call<T>(path: string, schema: z.ZodType<T>, init?: RequestInit): Promise<T> {
const res = await fetch(new URL(path, baseUrl), { ...init, headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...init?.headers } });
if (!res.ok) throw new ApiError(res.status);
return schema.parse(await res.json());
}
return {
getUser: (id: number) => call(`/users/${id}`, User),
createUser: (data: Omit<User, "id">) => call("/users", User, { method: "POST", body: JSON.stringify(data) }),
};
}
import { test, before, after, afterEach } from "node:test";
import assert from "node:assert/strict";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { createClient, ApiError } from "../src/client.ts";
const BASE = "https://api.example.com";
const server = setupServer(
http.get(`${BASE}/users/:id`, ({ params, request }) => {
if (request.headers.get("authorization") !== "Bearer t") return new HttpResponse(null, { status: 401 });
return HttpResponse.json({ id: Number(params.id), name: "Ada", email: "ada@example.com" });
}),
http.post(`${BASE}/users`, async ({ request }) => HttpResponse.json({ id: 7, ...(await request.json() as object) }, { status: 201 })),
);
before(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
after(() => server.close());
test("gets and parses a user", async () => {
assert.deepEqual(await createClient(BASE, "t").getUser(1), { id: 1, name: "Ada", email: "ada@example.com" });
});
test("posts a user", async () => {
const u = await createClient(BASE, "t").createUser({ name: "Bo", email: "bo@example.com" });
assert.equal(u.id, 7);
});
test("throws ApiError on 401", async () => {
await assert.rejects(createClient(BASE, "bad").getUser(1), (e) => e instanceof ApiError && e.status === 401);
});
test("rejects malformed payloads", async () => {
server.use(http.get(`${BASE}/users/:id`, () => HttpResponse.json({ id: "x" })));
await assert.rejects(createClient(BASE, "t").getUser(1));
});
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
Anthropic TypeScript SDK tool use loop, tested with a mocked fetch
Run a Claude tool_use loop with the official Anthropic SDK and Zod-validated tool inputs, tested offline with an injected fetch.
CSV import with PapaParse + Zod row validation
Parse CSV uploads with PapaParse (headers, BOM, quoted fields), validate each row with Zod and report row-numbered errors instead of failing the whole file.
Drizzle ORM + drizzle-zod validated inserts on SQLite
Generate Zod insert and select schemas from Drizzle tables with drizzle-zod, refine them, and validate input before inserting into SQLite.
Express 5 + Zod 4 request validation
Validate JSON bodies in Express 5 with a reusable Zod middleware that returns 400 with field errors.