The third-party API is not your model: adapters with Zod
When an API hands you `f_title` and `userid`, TypeScript does not complain if you copy it. Next day the listing already talks like them. I translate it on the server with Zod: `f_title` in, `title` out, and the screens get the object I actually want to use.
When you ask an API for data and you get 200, the temptation is to copy what arrived. I have done it. You open the JSON and you see f_title, f_descr, the price sometimes as text (f_price) and sometimes as a number (usdprice), a glued userid. To save time you put an interface or an any on it. TypeScript does not complain: the code is well-formed. It does not tell you whether the JSON is usable. Next day the listing is already reading ad.f_title.
That does not break the build. The fetch worked. What happened is their contract is already yours. If they change f_title to title tomorrow, the request stays green. You get a blank three screens down, and whoever is looking at the component has no reason to know the name was born outside.
That is where I use Zod. You write how the object arrives and how you want it to leave: f_title in, title out. The fetch tells you whether the network worked. Zod tells you whether that JSON is usable for what you are building.
And to be clear: an old API with f_ prefixes and a price that is sometimes a string and sometimes a number is normal. It was born for something else and nobody rewrites it. My criterion is not taking that dialect into the frontend, the hooks, three different screens. So I parse on the server, in the same place I talk to them. I validate, rename, and return the type the rest of the code actually understands.
A marketplace and APIs nobody was going to rewrite
In 2024 I had the chance to take part in the initial build of a classifieds marketplace. The website was new. The APIs were not. A dump, the JSON as it arrived, looked more or less like this:
{
"id": "4412",
"f_title": "Bicicleta de montaña",
"f_descr": "Poco uso",
"f_price": "12990.5",
"usdprice": 140,
"userid": "8821",
"f_contact": "Ana",
"categoryname": "Deportes"
}Look at that dump for a second. f_price is a string. usdprice is a number. userid and categoryname are glued. Nobody was going to rewrite those APIs. We were not going to copy that into the screen code either.
On the server we built the Zod schema: it listed what arrived, converted the price, returned title, price, user. The frontend imported Ad. It never read f_title.
The schema was not in the same file as the fetch. Types in one package, HTTP in another: two folders on the server, none on the frontend. The endpoint parsed and returned Ad. If the listing did not match, .parse() threw. The frontend did not get a half object.
That cleaned up the screens. It did not clean up everything. Filters and sort still sent f_price and f_added in the URL, because "the API asks for it that way". The criterion applies there too: if you do not cut the ugly name at the endpoint, it stays in the address bar.
The story of that project, without the steps, is in The frontend did not have to read f_title.
It happened to me again, on another project. The database columns were embarrassing: abbreviations, mixed language, a capital letter in the middle of a name. We were not hitting the database directly. There was an HTTP connector: you sent the query and it came back as JSON with those names. We did the same thing. Schema on the server, response parsed, an object you can say out loud. The frontend did not see Fch_Ult_Act. If the name is unreadable, every review is a translation. Same idea as the trigger naming convention, except here the "name" is the object that leaves the server.
The same cut works when you hire invoicing, a wallet, tracking. They almost never use your standard. The schema is there so your codebase does not inherit theirs.
How I implement it
The fetch brings you JSON. If you put a type on it right there ("this is already an Ad"), TypeScript believes you. It does not check whether f_title actually arrived. That is why I leave it as unknown: it still has no shape. Zod is what confirms it. With that clear, the rest is building the file.
1. The file that talks to the API is the one that translates
Two files. One asks for the data. The other renders the listing. The second one never reads f_title. If you switch invoicing providers tomorrow, you change the first one. The one that renders does not have to know.
listings/get-ad.ts ← translates: schema + fetch
components/ad-list.tsx ← renders: imports Ad, not the JSONThis, in the component, is what I do not want. The screen talks to the API and keeps f_title:
const payload = await fetch(`/ads/${id}`).then((r) => r.json());
setTitle(payload.f_title);In the marketplace the schema and the fetch lived in two folders on the server. It works, if nobody exports the raw JSON. What I leave here is stricter: same file. I have already seen someone import the ugly keys "just for the filter".
2. Write how it arrives and how you want it to leave
List their keys (f_title, userid). In the transform, the object your app already uses: title, userId, an id named id. If the price is sometimes missing, you mark it .optional() on the ugly side and give it a default on yours. Do not guess the price in the component.
import { z } from 'zod';
const listingAdSchema = z
.object({
id: z.string(),
f_title: z.string(),
f_price: z.string().optional(),
usdprice: z.number().nullable(),
userid: z.string(),
})
.transform((row) => ({
id: row.id,
title: row.f_title,
// ?? treats null (usdprice) and undefined (missing f_price) the same
price: Number(row.f_price ?? row.usdprice ?? 0),
userId: row.userid,
}));
type ListingAdInput = z.input<typeof listingAdSchema>;
type Ad = z.infer<typeof listingAdSchema>;The two lines at the bottom: ListingAdInput is what arrives (f_title). Ad is what leaves (title). The transform is that step. The component imports Ad. Not the first.
z.object drops keys you did not list. If the listing adds something, it does not enter Ad until you add it. You did not lose data: you did not ask for it.
3. Ask with safeParse and leave a trail if it does not fit
There are two ways to ask Zod whether the JSON is usable. .parse() throws an error if it does not match, and the request dies there: nobody gets a half object, but the log has no text you can search. safeParse tries and answers whether it worked, without throwing anything. I use safeParse and return one of two things: the Ad, or a searchable code in the logs (listing_ad_shape_changed), with parsed.error.issues to see which fields moved. In the marketplace, to be honest, we used .parse(). This version is the one I would leave today.
async function getAd(id: string): Promise<
| { success: true; data: Ad }
| { success: false; error: string }
> {
const payload: unknown = await fetchListing(`/ads/${id}`).then((r) =>
r.json(),
);
const parsed = listingAdSchema.safeParse(payload);
if (!parsed.success) {
return { success: false, error: 'listing_ad_shape_changed' };
}
return { success: true, data: parsed.data };
}Every response from outside goes through there. And what I do not do is as Ad: that is lying to TypeScript. This compiles and will not warn you the day they change f_title:
const ad = (await res.json()) as Ad;4. The listing only sees Ad
That file exports getAd and the type Ad. The frontend does not import ListingAdInput (the ugly keys). If a hook wants the raw JSON "just in case", just in case already won. If a filter has to send f_price because the API asks for it, that name stays where you fetch. Not on the screen. And if you can, not in the URL either.
export { getAd };
export type { Ad };The listing consumes that. It reads title, not f_title:
const result = await getAd(id);
if (result.success) {
result.data.title;
}Sort, if the API asks for it that way, is built where you fetch:
url.searchParams.set('sort', 'f_price');5. If a new field shows up, you add it
f_addr showed up and your schema does not have it: it does not exist on Ad. The day you need it, you add it to what arrives and to the transform. Until then, it is not there. They can change the API in silence. You do not.
const listingAdSchema = z
.object({
id: z.string(),
f_title: z.string(),
f_addr: z.string().optional(),
})
.transform((row) => ({
id: row.id,
title: row.f_title,
address: row.f_addr ?? '',
}));Now the one copying the JSON can be an agent
When we built the marketplace adapter, we were not asking an agent (the model writing on its own, not finishing your line) to do it. We wrote it ourselves. Today it leaks more easily. You ask it to plug in invoicing, a wallet, tracking, and the short path is pasting the JSON that arrived. It compiles. The frontend already talks like them.
If the repo already has the schema next to the fetch, a rule, and a skill, the agent copies that. It adapts to the outside API. The frontend does not. Same vice as Anti-Cliché: the model leaves you a dialect that is not from this repo, unless you told it where the border is. In .agents that lives as rules and skills, not as a comment someone has to remember to paste.
Quick reference
- Outside JSON comes in as
unknown. Zod confirms the shape withsafeParse. z.inputis their keys (f_title,userid).z.inferis your object (title,userId). The frontend only imports the second.- A key that is not in the schema does not enter
Ad. - If the parse fails, return a searchable code (
listing_ad_shape_changed) and checkparsed.error.issues. - No
as Ad, no.passthrough(), no translating on the screen. If the filter sendsf_price, it stays in the fetch.