Back to the blog
TypeScriptยท May 28, 2026ยท 8 min read

Type-safe API clients with Zod and fetch - without the codegen

Schema validation at the boundary, not just the form. How we replaced generated API clients with a thin Zod-typed fetch wrapper that's faster to maintain and easier to trust.

#typescript#zod#api#dx

Generated API clients feel appealing when you first set them up. You write an OpenAPI spec, run a command, and suddenly you have typed request and response objects. The problem is the gap between what the spec says and what the server actually returns in production. Specs drift. Generated clients don't validate at runtime. You get TypeScript confidence and runtime surprises.

The alternative: validate at the boundary

The pattern we landed on is a thin `apiFetch` wrapper that accepts a Zod schema and parses every response against it. If the schema doesn't match, you get an error immediately - at the network boundary, not three layers deep in your component tree when you try to read a field that isn't there. The TypeScript type is inferred from the schema, so there's no separate type declaration to maintain.

import { z } from 'zod';

async function apiFetch<T>(url: string, schema: z.ZodType<T>, init?: RequestInit): Promise<T> {
  const res = await fetch(url, init);
  if (!res.ok) throw new Error(`API error ${res.status}`);
  const data = await res.json();
  return schema.parse(data); // throws ZodError on mismatch
}

// Usage - the return type is inferred from the schema
const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  createdAt: z.coerce.date(),
});

const user = await apiFetch('/api/users/me', UserSchema);
// user: { id: string; email: string; createdAt: Date }

Handling optional fields and API evolution

The best part of this approach for long-lived APIs is how it handles schema evolution. When your backend adds a new field, the frontend schema doesn't need to change - Zod's `strip` behavior (the default) discards unknown fields silently. When the backend removes a required field, your schema throws a ZodError, surfacing the regression early instead of letting an `undefined` drift through your UI.

  • Use z.optional() for fields that genuinely may or may not be present - never use TypeScript's ?. operator as a silent ignore.
  • Use z.coerce.date() for date fields - API responses serialize dates as strings, not Date objects.
  • Use z.discriminatedUnion() for polymorphic response shapes - it gives better error messages than z.union() when the wrong variant is matched.
  • Collect schemas in a dedicated api/ folder alongside the fetch calls, not scattered in component files.

Error handling

A `ZodError` thrown from `schema.parse()` has a detailed `.issues` array that tells you exactly which field failed and why. Wrap your `apiFetch` calls in a try/catch and log the full ZodError in development - it saves significant debugging time compared to 'Cannot read properties of undefined.'

When this approach doesn't fit

If your API surface is very large (hundreds of endpoints) and you have strict contract tests that keep the spec truthful, a generated client is probably the right call - the overhead of manually maintaining 200 Zod schemas is real. The threshold for us is roughly 20 endpoints. Under that, typed fetch is strictly better. Over it, codegen starts to pay for itself, but only if you invest in the validation layer around the generated types.

Written by Appesto Engineering.