If you've written frontend code for more than a week, you've seen this one:
TypeError: Cannot read properties of undefined (reading 'name')It almost never means your code is broken in some deep way. Usually it means the frontend and the backend quietly stopped agreeing about what a piece of data looks like.
The classic way it happens goes something like this. The backend returns a customer_name field. Months later someone moves it into a nested customer object, which is a perfectly reasonable change. The backend tests pass. The frontend still compiles, because as far as TypeScript knows, nothing changed. Then a user opens the invoices page and it's blank.
The bug isn't in either codebase. It lives in the gap between them. These days most of my effort on API boundaries goes into making that gap impossible rather than being more careful around it.
Three ways to type data from an API
As far as I can tell, there are three common options, and I've used all of them.
1. Don't type it at all
const res = await fetch(`/api/invoices/${id}`)
const invoice = await res.json()
return <h1>{invoice.customer.name}</h1>res.json() returns Promise<any>, and any turns the type checker off for everything it touches. You can write invoice.customer.name, invoice.cusotmer.name or invoice.literally.anything, and TypeScript accepts all of them.
This is how a lot of projects start, and I get why. It's fast. It also means TypeScript is decoration at exactly the place you need it most: where data from another program enters yours.
2. Handwrite the types
interface Invoice {
id: number
customer_name: string
total: number
}
const invoice = (await res.json()) as InvoiceThis feels much better, and in a way it is. Autocomplete works and typos get caught.
But look at what happened. There's now a second description of an invoice. The real one lives in the backend code. This one is a copy, and a copy is only correct on the day you write it. When the backend changes, nothing tells this interface. And that as Invoice isn't a check. It's a promise you're making to the compiler, and the compiler believes you.
In some ways handwritten types are more dangerous than any. With any, you at least know you're on your own. A wrong interface looks exactly as trustworthy as a right one.
3. Generate the types from what the backend says it does
The third option is to stop keeping a copy.
The backend describes its API in an OpenAPI schema. A generator reads that schema and writes the TypeScript types and request functions. The frontend imports those and nothing else. If the backend changes, you regenerate, and the compiler shows you every place that no longer fits.
That's what every frontend I build does now.
What the pipeline looks like
My backends are mostly Rust, built on a small framework I maintain called Ferrax. I wrote about why it insists on typed route outputs, and this post is the payoff of that decision. Because every handler's inputs and outputs are real types, the OpenAPI schema is derived from the code instead of written next to it.
The flow in Ledgeo, the biggest app I work on, is three steps:
Rust handler types
|
| cargo run -- openapi
v
openapi/schema.json
|
| @hey-api/openapi-ts
v
src/generated/ (types + one function per endpoint)The backend can print its schema without starting a server, so generation doesn't need a database or any running services. The generator runs in Docker with every version pinned: the Node image, the generator package and the TypeScript version. A teammate's machine, CI and my laptop all produce the same output from the same schema.
A trimmed version of the Makefile target:
client-gen: openapi-schema
rm -rf src/generated
docker run --rm -v "$(ROOT_DIR):/local" -w /local \
node:22.22.0-alpine \
npx --yes -p typescript@6.0.2 -p @hey-api/openapi-ts@0.99.0 \
openapi-ts -i /local/openapi/schema.json -o /local/src/generated
bun run typecheckIt deletes the old output first. A generated folder should never keep a file from a previous version of the API just because nothing overwrote it.
The numbers are the argument
The strongest case for generation isn't philosophical. It's arithmetic.
Right now Ledgeo's schema has 207 paths, 281 operations and 706 named schemas. The generated types file is 31,843 lines long, and 684 files in the frontend import something from the generated client.
Nobody is going to handwrite 32,000 lines of types. And even if someone did, nobody could keep them correct by hand while the backend keeps moving. At that size, handwritten types don't just drift sometimes. Drift is guaranteed.
Calling an endpoint
Here's a real hook from Ledgeo that loads products for the review screen, lightly trimmed:
import { getApiOrgStorefrontReviewsProducts } from '@/generated/sdk.gen'
const { data, error, response } = await getApiOrgStorefrontReviewsProducts({
headers: { 'x-org-id': orgId },
query: { cursor: pageParam, search: search || null, limit: 20 },
})
if (error || !data) {
throw new QueryRequestError({ cause: error, kind: queryRequestErrorKind(response), response })
}A few things I don't have to think about here:
- The URL. I never type
/api/org/storefront/reviews/products. If the route moves, the function name changes, and every caller fails to compile. - The query parameters. If I misspell
cursoror pass a string forlimit, it's a type error. - The header. The endpoint declares that it needs
x-org-id, and the generated type makes it a required number. Forget it and the call doesn't compile. - The error path. The call returns
dataanderroras separate fields, so there's no way to pretend a failed request succeeded.
Yes, getApiOrgStorefrontReviewsProducts is an ugly name. I've made my peace with it. An ugly name that's always correct beats a pretty one I maintain by hand.
The part that actually lets me stop worrying
Generating the client is half of it. The other half is making sure nobody can forget to regenerate.
Ledgeo has a check for exactly that:
contract-check: client-gen
@git diff --exit-code HEAD -- client/openapi/schema.json client/src/generated/It regenerates the schema and the client from the current backend code, then asks git whether anything changed. If the committed client doesn't match what the backend would produce right now, the check fails. There's a second step that also fails if the generator created new files that were never committed.
This runs as part of the full verification suite, and the repo's contributor guide says any API change updates the backend, the emitted schema, the generated SDK and the frontend code that uses it in the same branch.
So the rename from the start of this post plays out very differently. Someone moves customer_name into customer.name in the Rust code. The schema changes. The generated Invoice type changes. tsc fails on every line that still reads customer_name, and those get fixed before the branch can merge. The error moves from a user's browser to someone's terminal, which is exactly where I want errors to be.
That's what I mean by worry-free. It's not that I'm more careful than before. It's that the carefulness lives in the build, so I don't have to carry it around in my head.
Generated types are only as good as your compiler settings
One thing I learned the slow way: generating types doesn't help much if TypeScript is set up to be forgiving.
If the backend field is an Option<String> in Rust, the schema says it's nullable, and the generated type is string | null. That's only useful if the compiler makes you deal with the null. So the frontend runs with strict, noUncheckedIndexedAccess and exactOptionalPropertyTypes. With those on, a nullable field from the server can't reach the screen until the code has decided what to do when it's missing.
Put another way, the schema tells the truth and the compiler makes sure I listen to it.
Where I got this wrong
I wasn't always this strict about it, and the history shows it.
In net-pilot, an older tool of mine, the generator step adds // @ts-nocheck to the top of every generated file. It's the quickest way to make type errors in generated code go away, and that's exactly the problem with it. Worse, on top of a perfectly good generated union type for the router diff, I hand-wrote this:
export type DiffItemFlat = { kind: string; data: Record<string, unknown> }The generator had done the hard work of describing every diff variant exactly, and I threw it away for a shape that basically says "some object". The page works, but that part of it is back to trusting instead of knowing.
Ledgeo is where I closed those holes. Its generation step runs Biome over the generated code specifically to catch suppression comments, fails outright if any @ts-nocheck, @ts-ignore or @ts-expect-error shows up, and type-checks the generated code on its own before the app is checked. If generated code doesn't type-check, that's a bug worth fixing at the source, not a warning to silence.
One schema, more than one client
Once the schema is the source of truth, the frontend isn't the only thing that benefits.
At one point I built a Flutter app against the same Ledgeo backend. It didn't need a separate API layer written by hand in Dart. It generated a Dart client from the same schema with openapi-generator.
It wasn't entirely smooth. The Dart generator handled some string enums badly, so there's a small script that normalizes the schema before generation, and it lists 24 enum types by name. Generators have rough edges, and it's better to patch the input in one visible place than to hand-edit the output.
What this doesn't solve
I don't want to oversell it.
- The types trust the server. Generated types describe what the backend says it returns. If the backend's schema is wrong, the client is confidently wrong too. This is why I care so much about the schema being derived from real handler types instead of written separately.
- Version skew still exists. Someone with a tab open from yesterday's deploy is running yesterday's client against today's API. Generation doesn't fix that. Only compatible API changes and sensible deploys do.
- The diffs are noisy. Change one field and a pull request can touch a surprising number of generated lines. I've learned to review the schema diff and mostly skim the generated one.
- You need to own the backend, or at least trust its spec. For third-party APIs with a sloppy or missing schema, you're back to validating at runtime. That's a job for something like Zod.
Why it's worth it anyway
There used to be a list in my head of things to keep in sync. The backend struct, the TypeScript interface, the fetch URL, the query parameter names, which fields could be null. Every API change meant walking that list and hoping I didn't skip a line.
That list is gone. There's one description of the API, and it lives in the backend code. Everything else is derived from it and checked against it. When I change an endpoint, I don't wonder what I forgot. The compiler tells me, and the build won't let me merge until it's quiet.
That's the whole trick, really. Don't try harder to keep two things in sync. Make sure there's only one.
comments
view on github ->