I wired up a small public crypto price API last week and did the thing you are supposed to do: fetched its OpenAPI document and generated a client instead of hand-rolling fetch calls.
Half of it worked beautifully. The other half taught me something about what these documents usually contain.
What the spec gave me
The document is small, four operations, and the parameters are described properly:
{
"name": "days",
"in": "query",
"schema": { "type": "integer", "default": 30, "maximum": 365 }
}
That one object is worth more than it looks. My generated client knows days is an integer, knows it defaults to 30, and knows 400 is out of range. The same goes for maxPoints, capped at 500, and perPage, capped at 250. Authentication is in there too, an apiKey in the x-api-key header, so the client wires the header itself rather than leaving me to remember it.
Requests, in other words, are a solved problem. I cannot send a malformed one without the compiler objecting first.
What it did not give me
Here is a complete response definition from the same document:
"responses": {
"200": { "description": "Historical price series" },
"401": { "description": "Missing or invalid API key" },
"404": { "description": "Token not found" },
"429": { "description": "Rate limit exceeded" }
}
A description, and nothing else. No schema, and components.schemas is empty. So the generated client hands me back unknown for the only part of the exchange I actually consume.
This is not a criticism of one API. Go and read the specs of the services you use. Parameters described, responses hand-waved, is the ordinary state of the art, and the reason is not laziness. Describing a request is describing four or five scalars you already validate. Describing a response means modelling every nested object you return and keeping that model true for as long as the endpoint lives.
What I do about it now
I stopped trying to get one artefact to do both jobs.
The generated client handles the request side, because that part of the spec is real. For responses I make one live call, look at what comes back, and write the type by hand:
import { z } from "zod";
const PricePoint = z.object({
ts: z.string(),
price: z.number(),
pctChange: z.number(),
});
const History = z.object({
meta: z.object({
symbols: z.array(z.string()),
days: z.number(),
maxPoints: z.number(),
}),
data: z.record(z.object({
history: z.array(PricePoint),
volatility: z.number(),
maxDrawdown: z.number(),
})),
});
type History = z.infer<typeof History>;
Writing it as a schema rather than a bare type costs nothing extra and buys the part I used to skip, which is validating at the boundary rather than trusting either source:
const parsed = History.safeParse(await res.json());
if (!parsed.success) {
// the shape moved; fail here, loudly, not three layers in
throw new Error(`unexpected response: ${parsed.error.message}`);
}
A hand-written type is a guess about today. A runtime check is what tells you the guess has expired. Generated types give you neither, because unknown cannot be wrong.
The test I apply now
Before I commit to an API I ask two questions of its document. Does it stop me sending a bad request? Usually yes. Does it tell me the shape of what comes back? Usually no, and that is the half I have to own.
The API above is The Coin Analysis, free, three requests an hour per endpoint on the free tier, which is another thing worth knowing before you build a polling loop around it. But the point is not that one. Open the spec of whatever you are about to integrate and scroll to responses. You will learn in ten seconds how much work is still yours.
Top comments (0)