# 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:

```json
{
  "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](https://www.enderpuentes.com/en/case-studies/el-frontend-no-tenia-por-que-hablar-como-el-erp).

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](https://www.enderpuentes.com/en/notes/data-engineering/postgres-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 JSON
```

This, in the component, is what I do not want. The screen talks to the API and keeps `f\_title`:

```typescript
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.

```typescript
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.

```typescript
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`:

```typescript
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.

```typescript
export { getAd };
export type { Ad };
```

The listing consumes that. It reads `title`, not `f\_title`:

```typescript
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:

```typescript
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.

```typescript
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é](https://www.enderpuentes.com/en/notes/ai-engineering/antes-tardaba-mas-arreglando-que-escribiendo): the model leaves you a dialect that is not from this repo, unless you told it where the border is. In [.agents](https://www.enderpuentes.com/en/notes/ai-engineering/agents-centralized-rules-and-skills-for-any-ai-editor) 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 with `safeParse`.
- `z.input` is their keys (`f\_title`, `userid`). `z.infer` is 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 check `parsed.error.issues`.
- No `as Ad`, no `.passthrough()`, no translating on the screen. If the filter sends `f\_price`, it stays in the fetch.
