My backend serves three applications: an admin interface, a web frontend and a mobile app. Two are built with Nuxt, one with React Native. That leads to a question that sounds trivial and is expensive anyway: how do those three actually know what the API looks like?
Meaning: which fields exist? Which ones can be null? What comes back for a list, what for a single object?
The usual answer is: you write it down. And that is exactly where the problem starts.
Because a description you maintain by hand is documentation. And documentation goes stale. Not because anyone is sloppy, but because it is a second source next to the code, and nobody keeps two sources identical forever. A description that is derived from the code itself, and whose deviation turns the build red, is something else entirely: a guarantee.
Before: everyone copies the types
The starting point was completely normal. One API file per feature in the frontend, plus hand-maintained types that also existed in the backend.
// maintained by hand, in parallel to the backend
export interface Article {
id: string
title: string
publishedAt: string | null
author: Author
}
export async function fetchArticles(page: number): Promise<Paginated<Article>> {
const res = await fetch(`/api/articles?page=${page}`)
return res.json()
}
This is not a straw man. That is what most projects look like, and it works too – until it doesn't.
The catch: these types are a promise nobody checks. In the frontend I claim publishedAt can be null. Whether the backend agrees, I don't know. If an optional field becomes required in the backend, nobody in the frontend notices, because the question mark is still sitting there. TypeScript checks everything except the one place that matters: where the data actually arrives over the wire.
And because each of the three applications maintains its own copy, the same pagination type ends up existing three times, in slightly different variants.
Why I don't simply import the types
The obvious way out: if backend and frontend live in the same repository, just import the types from the backend. For AdonisJS there is even a dedicated package for it. It is in my project and it is in use – my tests get typed route names through it.
For the path to the clients it still doesn't hold up. For two reasons.
First, an import like that is a hard link between two projects. The client has to read and resolve the backend source. As long as both sides share the same TypeScript world, that works fine. The mobile app does not: different bundler, different build pipeline, different toolchain. That is exactly where the link gets uncomfortable.
Second, TypeScript types disappear at compile time. They no longer exist at runtime. So I cannot generate anything from a type that is needed at runtime – no validation, no ready-made data-fetching helpers for two different frontend worlds.
I needed something that does not depend on TypeScript: a plain file that describes the API, language-agnostic, and that you can generate arbitrarily much from. That is OpenAPI.
The description writes itself
That file lives in my repository. But nobody types it. A custom command generates it from what is already there.
For the incoming side that is easy. Validators already exist that check every request before it is processed. The code that rejects wrong input therefore also knows which input is right. It gets read out and translated into descriptions.
The outgoing side is the more interesting part. In my setup, one class builds the finished API response from a database record – a transformer. And because its method has a written-out return type, it already states what the response looks like. A tool called ts-morph reads the code the way the TypeScript compiler reads it, and turns that return type into a schema.
From this method:
export class ArticleTransformer {
toDetail(article: Article): ArticleDetailResponse {
return {
id: article.id,
title: article.title,
publishedAt: article.publishedAt?.toISO() ?? null,
author: this.authors.toSummary(article.author),
}
}
}
this description is generated automatically:
ArticleDetailResponse:
type: object
required: [id, title, publishedAt, author]
properties:
id: { type: string }
title: { type: string }
publishedAt:
type: [string, 'null']
author:
$ref: '#/components/schemas/AuthorSummary'
So the code that builds the response also describes it. That is the whole trick.
The generator knows four shapes: single object, list, and two kinds of pagination. If it hits something it doesn't know, it aborts with an error. It deliberately does not write an empty schema in that case – because an empty schema is a lie nobody notices.
There was one side effect I hadn't accounted for. For the types to be readable, every transformer method has to spell out its return type. Inferred types are not enough. Today a lint rule enforces exactly that.
So the generator dictates a code style back into the backend. That is bearable. But it is a side effect, and I would rather say so up front.
The important part is that it breaks loudly
Almost everything you find online on this topic stops at "I generate a client from a Swagger URL". That is the boring part. It gets interesting at the question of what actually turns things red.
And there are two completely different cases here that should not be conflated.
Case 1: someone forgot to regenerate
The easy one. CI regenerates the description, regenerates the client, and compares both against what is checked in:
node ace openapi:generate
pnpm api-client:generate
git diff --exit-code openapi.yaml packages/api-sdk/src
Three lines that look like hygiene. The case they exist for looks like this: two feature branches each change the API. Both are green on their own, both dutifully regenerated. After merging, the generated client is stale anyway – it only knows the change from one of the two branches.
No type check in the world finds that. The generated code is perfectly coherent with itself. It just isn't the truth anymore. The only way it can surface is by holding it against a freshly generated version.
That case happened to me. It is the reason I now consider that comparison more important than the generator itself.
Case 2: the description lies
The unpleasant one. A description derived from types proves exactly one thing: that the code matches itself. It does not prove that the response actually shipped matches it.
So there is a second kind of test. It calls real endpoints and checks the real response against the description.
The find that proved it to me was a simple counter. The value came from a SQL COUNT. PostgreSQL returns that as text, not as a number. The code said number anyway:
toStats(row: StatsRow): ArticleStatsResponse {
return { articleCount: row.count }
}
And this is what went over the wire:
{ "articleCount": "42" }
The type check was green. The generator was happy. The description said "number", what got delivered was text in quotes.
The type itself was lying here. Which is why no tool that reads types could ever have found it – the generator dutifully copied the wrong claim. The only thing that went red was the test holding the real response against the schema.
The same round surfaced a pagination type that didn't match the real response. Later came endpoints with no written-out response at all, whose schema was guesswork accordingly.
A third find changed my picture of where you actually fix things. In the admin there were casts in a few places – an as in the code, telling the compiler: "trust me, the type is fine." The reflex is to leave it there and move on. In reality every one of those casts marked a gap in the API description. When the gaps were closed, the casts disappeared on their own.
The casts were the symptom, the gap was the cause.
What the three applications get out of it
From that one file, @hey-api/openapi-ts generates everything else: ready-made typed functions for every endpoint, a client to send them with, matching data-fetching helpers for both frontend worlds – Vue for web and admin, React for mobile – and validation schemas.
All of it lands in one shared package, and each app pulls only what it needs. In the frontend it then looks like this:
import { useQuery } from '@tanstack/vue-query'
import { getArticlesOptions } from '@myapp/api-sdk/tanstack/vue-query'
const { data } = useQuery(getArticlesOptions({ query: { page: 1 } }))
I wrote none of those lines, and none of them will go stale without somebody noticing.
The number that made it tangible for me: when I migrated the admin data layer, the casts went from 21 down to 2. The 2 matter more to me than the 21. It does not go to zero, and that is correct – at a system boundary a deliberate narrowing sometimes remains. Anyone promising zero has just hidden the casts somewhere else.
On validation I have to stay honest, because the obvious story would be wrong here. 385 schemas are generated. Only the ones for outgoing data are actually used – for forms, partly extended with rules the API cannot possibly know about:
import { zCreateArticleBody } from '@myapp/api-sdk/zod'
// the API doesn't know this rule – the interface does
const formSchema = zCreateArticleBody.extend({
title: zCreateArticleBody.shape.title.max(80, "Won't fit the preview otherwise"),
})
Checking incoming responses sits fully generated in the repository, but it is not switched on. The sentence "types only hold at compile time, the network doesn't" still applies. I am currently only cashing it in on one side.
Whether this pays off comes down to one number
The effort for a setup like this is almost entirely fixed. Whether the API has ten or a hundred endpoints barely matters – the generator has to be built once and maintained afterwards. The benefit, on the other hand, grows with every application using the API.
The number it hinges on is the number of applications, not the number of developers. With a single client it probably isn't worth it. Importing the types straight from the backend is enough there, and if something doesn't line up, the next build says so.
I am, by the way, the only developer on this project. That is precisely why the setup carries its weight: there is nobody who might notice a deviation by chance. And holding three clients in your head at once works exactly as long as you keep working on all three without interruption.
The tipping point for me was the third client – and the fact that one of them leaves the shared TypeScript world. Rolling it out across all features grew the description from 30 to 62 endpoints. Keeping that in sync by hand would have been a full-time job.
Two things are worth knowing up front anyway, because no tutorial mentions them.
The generator is code itself. It knows exactly the response shapes that occur in this project. Every new shape needs an extension. That is infrastructure that wants maintenance and that no client will ever lay eyes on.
It feeds back into code style. The enforced return types are a small price, but they are one: a rule that has nothing to do with the actual feature and that you have to explain to every new team member.
Both are bearable. But they are the difference between "I generate myself a client" and "I operate a generation step".
What I took away is less a technology recommendation than a different way of thinking. I used to try to maintain the API description: carefully, with discipline, with good intentions. That works for a while and then fails on a Tuesday afternoon when three things are urgent at once.
Today I don't maintain it anymore. I let it be derived and make sure every deviation gets loud. The difference isn't diligence. The difference is that diligence is no longer required.
A contract isn't a document you maintain. It's a property you enforce.
