Skip to content
Emmanuel Yegon

Tutorial

Build a type-safe API client with TypeScript and Zod

TypeScript types disappear at runtime. Here is how to validate API responses so a bad payload fails loudly at the edge instead of deep in your UI.

Emmanuel Yegon3 min read

You wrote a User interface, fetched /api/users/1, and typed the result as User. The compiler is happy. Then the backend renames name to fullName, and your UI quietly renders undefined.

TypeScript only checks the code you wrote. It cannot check the JSON that arrives over the network. In this tutorial you will build a small client that validates every response against a schema, so a changed payload fails immediately with a clear error.

What you will build

A request() function that takes a URL and a Zod schema, and returns data whose type is inferred from that schema. You will need Node 20 or newer and a TypeScript project.

npm install zod

Describe the data once

Define the shape as a Zod schema, then derive the TypeScript type from it. You maintain one definition instead of an interface and a validator that can drift apart.

schemas.ts
import { z } from "zod";
 
export const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.email(),
});
 
export type User = z.infer<typeof UserSchema>;

Write the request helper

The helper fetches, checks the status code, then parses the body. The generic parameter ties the return type to the schema you pass in.

request.ts
import { z } from "zod";
 
export class ApiError extends Error {
  constructor(
    public status: number,
    message: string,
  ) {
    super(message);
    this.name = "ApiError";
  }
}
 
export async function request<S extends z.ZodType>(
  url: string,
  schema: S,
  init?: RequestInit,
): Promise<z.infer<S>> {
  const res = await fetch(url, init);
  if (!res.ok) throw new ApiError(res.status, `Request to ${url} failed`);
 
  const json: unknown = await res.json();
  return schema.parse(json);
}

Notice that json is typed as unknown, not any. TypeScript now forces you to validate it before use, which is the whole point.

Use it

The return type is inferred, so there is nothing to annotate.

app.ts
import { request } from "./request";
import { UserSchema } from "./schemas";
 
const user = await request("https://api.example.com/users/1", UserSchema);
 
console.log(user.name.toUpperCase()); // `user` is fully typed

For a list, compose the schema instead of writing a new one:

const users = await request("https://api.example.com/users", UserSchema.array());

Handle failures on purpose

A validation failure throws a ZodError, and a bad status throws your ApiError. Catch them separately because they mean different things.

import { z } from "zod";
import { ApiError } from "./request";
 
try {
  const user = await request("https://api.example.com/users/1", UserSchema);
  render(user);
} catch (error) {
  if (error instanceof ApiError) {
    showMessage(`The server returned ${error.status}.`);
  } else if (error instanceof z.ZodError) {
    reportBug(error.issues); // the API contract changed
  } else {
    throw error;
  }
}

Where to go next

  • Add .optional() and .default() to fields the API sometimes omits.
  • Use z.coerce.date() to turn ISO strings into Date objects as part of parsing.
  • Wrap request() in per-endpoint functions so call sites never see URLs.

Once responses are validated at the boundary, the rest of your code can trust its types, because they now describe what is actually there.

Comments