TypeScript trusts your API too much, so I added Zod

A backend rename turned a typed field into undefined and the compiler never blinked, because types are gone at runtime. So now the data gets checked where it comes in.

Two weeks ago the backend team renamed created_at to createdAt on one endpoint, announced it in the channel, and I missed the message. The frontend kept compiling. TypeScript said the user had a createdAt, the API said it didn't, and the profile page showed "Invalid Date" for a day until someone noticed.

That's not a TypeScript bug. The type was my promise about the API, written in a .ts file, and the compiler checked my code against my promise. Nobody checked the promise.

ts
// src/api/user.ts, the before
export type User = {
  id: number;
  name: string;
  createdAt: string;
};

export async function fetchUser(id: number): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  return response.json() as Promise<User>;
}

That as is where the lie lives. response.json() returns Promise<any>, and any becomes whatever you tell it to.

Types stop at the network

Everything that comes from outside the process is unknown to me now: fetch responses, localStorage, URL params, messages from a worker, files from disk. Inside the process TypeScript is excellent. At the edge it has nothing to go on, and the honest type for data you haven't looked at is unknown.

So let's look at it. The tool I picked is Zod by Colin McDonnell: you write a schema, the schema parses the data, and the type comes out of the schema.

ts
// src/api/user.ts, the after
import { z } from 'zod';

export const User = z.object({
  id: z.number(),
  name: z.string(),
  // the API sends an ISO string, coerce turns it into a Date and rejects garbage
  createdAt: z.coerce.date(),
});

export type User = z.infer<typeof User>;
// { id: number; name: string; createdAt: Date }

The schema is the one source. I don't write the type by hand anymore, z.infer derives it, and the same schema is the runtime check. One place to update when the API changes, and if I forget, it fails at the call with the field name, instead of three components later with "Invalid Date".

One helper for every call

ts
// src/api/client.ts
import type { z } from 'zod';

export async function fetchJson<S extends z.ZodTypeAny>(url: string, schema: S): Promise<z.infer<S>> {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`${response.status} ${response.statusText} for ${url}`);
  }
  return schema.parse(await response.json());
}

// the return type is User without saying so
const user = await fetchJson(`/api/users/${id}`, User);

parse throws a ZodError when the shape is wrong, and the error lists every issue with its path, in my case createdAt and "Invalid date". Had this been in place, the rename would have shown up as one red line in the console, on the first request, with the field named. A day of wrong dates versus a minute of reading an error.

When you'd rather not throw

For a form being saved or a config file being loaded I want the errors, not an exception. safeParse returns a union instead:

ts
const result = User.safeParse(data);
if (!result.success) {
  // result.error.issues: [{ path: ['createdAt'], message: 'Invalid date' }, ...]
  return showErrors(result.error.flatten().fieldErrors);
}
// result.data is a User from here on

{ success: true; data } | { success: false; error } is a discriminated union, the kind I wrote about last year, and it's a good sign when a library's API is shaped like the types it wants you to write.

What it costs

  • Bytes. Zod is not tiny and it ships to the browser. Look at your bundle analyzer and decide. For the app at work it was worth it, for a landing page it wouldn't be.
  • Time. Parsing is a loop over the fields. Nothing got slow for us, so I haven't measured it. For a stream of thousands of events a second I would, before deciding.
  • Discipline. It only helps at the edge. Validating data you built yourself two functions ago is noise, and I've seen codebases where every function parses its arguments. Don't.

What else is out there

Zod is not the only one and it isn't always the best choice. If the backend publishes an OpenAPI spec, generating the types from it with openapi-typescript beats writing schemas by hand, because the spec is the contract and it belongs to the backend. io-ts, yup, superstruct and ajv with JSON Schema all do the job too. I picked Zod for the z.infer story and the error messages, not because the others are wrong.

Whichever you pick, the rule is the same: parse at the edge, trust everything inside. Alexis King's "Parse, don't validate" is the longer version of this argument and worth the read.

Read more here: