Alle Beiträge
typescript api backend

Building Type-Safe APIs with TypeScript

· 1 Min. Lesezeit


Working with REST s in often starts the same way: fetch, JSON.parse, and an as any cast that everyone agrees to forget about. It works until it doesn’t — and when it doesn’t, you’re debugging a Cannot read properties of undefined error in production.

Here’s the pattern I’ve settled on after too many of those nights.

The Core Wrapper

Instead of scattering fetch calls across components, I use a thin wrapper that encodes success and failure at the type level:

type ApiResult<T> =
  | { ok: true; data: T }
  | { ok: false; error: string; status: number };

async function apiFetch<T>(
  path: string,
  init?: RequestInit,
): Promise<ApiResult<T>> {
  try {
    const res = await fetch(`/api${path}`, init);
    if (!res.ok) {
      return { ok: false, error: res.statusText, status: res.status };
    }
    const data: T = await res.json();
    return { ok: true, data };
  } catch (err) {
    return { ok: false, error: String(err), status: 0 };
  }
}

The caller is forced to check ok before touching data. TypeScript enforces this via the discriminated union — you physically cannot access data on the error branch.

Usage

const result = await apiFetch<User>('/users/42');

if (!result.ok) {
  console.error(result.error); // only available on the error branch
  return;
}

console.log(result.data.name); // TypeScript knows data is User here

Runtime Validation with Zod

Types disappear at runtime. Pair the wrapper with to validate at the boundary:

import { z } from 'zod';

const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
});

type User = z.infer<typeof UserSchema>;

async function getUser(id: string): Promise<ApiResult<User>> {
  const result = await apiFetch<unknown>(`/users/${id}`);
  if (!result.ok) return result;

  const parsed = UserSchema.safeParse(result.data);
  if (!parsed.success) {
    return { ok: false, error: 'Unexpected response shape', status: 0 };
  }

  return { ok: true, data: parsed.data };
}

The schema acts as a contract: if the API changes its response shape, your build won’t fail — but your tests will, which is the next best thing.

Ähnliche Beiträge