Designing Type-Safe APIs Across Frontend and Backend

I walk through how to share Zod schemas between frontend and backend, why runtime validation at API boundaries matters, and when OpenAPI codegen is the better fit.

Designing Type-Safe APIs Across Frontend and Backend

You rename a field in your backend response from createdAt to created_at. The TypeScript type in the frontend package still uses createdAt. The build passes. The tests pass. The app ships.

Three days later someone files a bug: the date column in the UI is empty. The field was there yesterday — now it’s undefined, quietly, with no error anywhere in the stack.

This happens because TypeScript types don’t cross the network. The annotation const user = data as User is a promise to the compiler, not a check on the data. Once your code compiles to JavaScript, the types are gone. Nobody is verifying the shape of what actually arrived.

The question isn’t whether TypeScript is useful — it clearly is. The question is: what checks the shape of data when it arrives from somewhere you don’t fully control?

Types describe what you expect. Schemas verify what you actually got.

On this page

The part TypeScript can’t check

TypeScript’s type checking runs at compile time. When your code compiles to JavaScript, every interface, type, and generic annotation is erased. The runtime has no record of them.

This is fine for most of your code — the compiler catches errors before they can run. But it creates a specific problem at API boundaries. When your frontend calls fetch('/api/user'), the response is raw JSON. Calling response.json() returns Promise<any>. You can annotate it as User, but the annotation is a trust statement. TypeScript will believe you.

If you’ve looked at TypeScript generics before, you’ve seen a version of this:

async function getUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  return response.json() as User; // no validation here
}

The compiler is satisfied. The return type says User. But at runtime, response.json() can return anything — a renamed field, a missing property, a completely different shape if the server was updated this morning and the frontend hasn’t deployed yet.

TypeScript protects you from inconsistency inside your codebase. It cannot protect you from inconsistency at external boundaries. The gap is real, and it doesn’t go away just because both sides of the stack are written in TypeScript.

Schema-first design: let the schema own the type

The schema-first approach inverts the usual relationship. Instead of writing a TypeScript type and trusting data will match it, you write a schema that can validate data at runtime, then derive the TypeScript type from that schema.

Zod is built exactly for this. A Zod schema is a JavaScript object that knows how to check a value at runtime. The z.infer<> utility then extracts the equivalent TypeScript type:

import { z } from "zod";

const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  role: z.enum(["admin", "member"]),
  createdAt: z.string().datetime(),
});

type User = z.infer<typeof UserSchema>;
// { id: string; email: string; role: "admin" | "member"; createdAt: string }

User is now derived from UserSchema. They cannot drift apart. If you rename createdAt to created_at in the schema, the type updates automatically. Every call site that reads user.createdAt immediately shows a TypeScript error — before you run anything.

At the API boundary, you use .safeParse() instead of casting:

const result = UserSchema.safeParse(await response.json());

if (!result.success) {
  // result.error.issues gives field-level detail
  console.error(result.error.issues);
  throw new Error("Unexpected user shape from API");
}

const user = result.data; // typed as User, shape was verified

safeParse returns a discriminated union: either { success: true, data: User } or { success: false, error: ZodError }. No try/catch needed. The error carries an issues array with the path and message for each field that failed, so you know exactly what didn’t match and where.

One schema package, both sides of the stack

This is where schema-first pays off most clearly. In a monorepo, you move the schemas into a shared package that both the frontend and backend import.

// packages/schemas/src/user.ts
import { z } from "zod";

export const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  role: z.enum(["admin", "member"]),
  createdAt: z.string().datetime(),
});

export type User = z.infer<typeof UserSchema>;

The backend uses the same schema to validate request bodies. Zod gives you .omit() to derive a subset schema without rewriting anything:

// apps/backend/src/routes/users.ts
import { UserSchema } from "@myproject/schemas";

app.post("/users", async (req, res) => {
  const result = UserSchema
    .omit({ id: true, createdAt: true })
    .safeParse(req.body);

  if (!result.success) {
    return res.status(400).json({ errors: result.error.issues });
  }

  const newUser = await db.user.create({ data: result.data });
  res.status(201).json(newUser);
});

The frontend uses the same schema to validate the response:

// apps/frontend/src/api/users.ts
import { UserSchema, type User } from "@myproject/schemas";

export async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  const json = await response.json();
  return UserSchema.parse(json); // throws ZodError if shape is wrong
}

When you change the schema — add a field, rename one, tighten a validation rule — TypeScript flags every affected call site in both packages at once. There’s no out-of-date type definition sitting in a separate file waiting to cause a silent mismatch.

The same principle applies to Next.js route handlers: parse the request body with the schema on the way in, and use the inferred type on the way out.

Check this before moving on

  • Your schemas live in a shared package, not duplicated in each app
  • You’re calling safeParse or parse at boundaries, not as YourType
  • The TypeScript type is derived with z.infer<>, not written separately

Building a typed fetch client around your schema

For APIs with many endpoints, writing a safeParse call inside every fetch function gets repetitive. A small typed request helper centralises the pattern:

import { z } from "zod";

async function get<T>(url: string, schema: z.ZodType<T>): Promise<T> {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`Request failed: ${response.status}`);
  }
  const json = await response.json();
  return schema.parse(json);
}

// Usage
const user = await get(`/api/users/${id}`, UserSchema);
// user is typed as User — and the shape was actually checked

This keeps each individual call site clean while ensuring validation always runs. The schema doubles as documentation: look at any call and you immediately know what shape to expect.

For a REST API with Express, the mirror image is a parseBody helper that runs safeParse on req.body before the handler logic runs. You reject malformed requests at the edge rather than letting them propagate into your database layer.

When OpenAPI codegen makes more sense

Shared Zod schemas work well when you own both ends of the stack and they live in the same codebase. There are situations where that assumption breaks down.

If you’re building a public API — something third-party developers will consume in different languages — an OpenAPI spec is a better anchor. The spec describes your API in a language-agnostic format, and tools like @hey-api/openapi-ts generate typed TypeScript clients from it:

// Generated from your OpenAPI spec by @hey-api/openapi-ts
import { getUserById } from "./generated-client";

const { data: user } = await getUserById({ path: { id: "abc-123" } });
// user is typed from the spec

You maintain the spec, the codegen runs on a build step or commit hook, and every consumer gets a client whose types match the current spec. The generated types aren’t something you edit — they’re derived output, just like z.infer<>.

The limitation: the spec is now a second source of truth. If your implementation drifts from the spec before you regenerate, the types are lying again. Some teams solve this by generating the OpenAPI spec from Zod schemas using a library like zod-to-openapi — you maintain one schema and get both runtime validation and the public spec from it.

For GraphQL APIs, the schema is the spec and codegen from it is already the standard practice. That distinction is explored in more depth over in GraphQL vs REST.

What breaks without runtime validation

The shortcut I see most often is the type assertion:

const user = (await response.json()) as User;

This compiles. It runs. It gives you autocomplete. And it provides zero protection against a shape that doesn’t match.

The bugs this creates tend to be quiet. A field that was a string is now a number. A property moved from user.address to user.contact.address. An optional field your code assumes is always present. None of these break immediately — they produce undefined reads, silent NaN calculations, or wrong display values that take days to trace back to the original fetch call.

The reason developers accept this trade-off is that it feels safe while writing. The type says User, the API returns a User, so it matches. The problem is time: APIs change, and the type annotation doesn’t automatically update to reflect those changes.

Runtime validation shifts the failure point. Instead of something breaking three steps downstream in the UI, the mismatch surfaces at the moment the data arrives — with a clear message about which field failed and why.

Choosing between parse and safeParse in production

.parse() throws a ZodError on failure. In a frontend fetch helper, that’s often fine — you’ll want to propagate the error up and show an error state.

.safeParse() returns the result without throwing. In server-side code, this is usually the better choice because you want to control the HTTP response rather than let an unhandled exception reach the error handler:

const result = UserSchema.safeParse(req.body);
if (!result.success) {
  return res.status(400).json({ errors: result.error.issues });
}
// result.data is your validated payload

Both give you the same shape information — .safeParse() just keeps that information inside the result object instead of throwing it.

Picking the right approach for your project

Your situation Approach Reason
Monorepo, TypeScript on both ends Shared Zod schemas One change updates both sides; no spec file to maintain
Public API with multiple consumers OpenAPI spec + codegen Language-agnostic; consumers don’t import your source package
Existing REST API, no spec yet Zod schemas for responses Start validating gradually without restructuring everything
Third-party API you don’t control Zod schemas for responses Validate the contract even when you can’t enforce it on the server

The choice usually comes down to two questions: do you own both ends, and do you have consumers who aren’t TypeScript projects?

If you own both ends and everyone’s on TypeScript, shared schemas are simpler — no spec file to maintain, no codegen step, and changes propagate immediately through the type system.

If the API is public or has non-TypeScript consumers, an OpenAPI spec is worth the overhead. It’s the standard contract format outside the TypeScript ecosystem, and the tooling around it is mature.

What isn’t a real choice is skipping runtime validation entirely. A TypeScript type at an API boundary is an assertion, not a guarantee. The schema is what makes the assertion real.

If you’re coming to this from a TypeScript background, TypeScript for JavaScript developers covers the fundamentals that make schema inference useful — specifically why z.infer<> works the way it does. If you want a tighter coupling between client and server with no type-casting at all, the GraphQL approach removes the boundary problem in a different way.

Sources