# La API del tercero no es tu modelo: adapters con Zod

Cuando una API te suelta `f_title` y `userid`, TypeScript no se queja si lo copias. Al otro día el listado ya habla como ellos. Yo lo traduzco en el server con Zod: entra `f_title`, sale `title`, y a las pantallas les llega el objeto que sí quiero manejar.

Cuando pides datos a una API y te responde 200, la tentación es copiar lo que llegó. Lo he hecho. Abres el JSON y ves `f\_title`, `f\_descr`, el precio a veces como texto (`f\_price`) y a veces como número (`usdprice`), un `userid` todo pegado. Para no perder tiempo le pones un `interface` o un `any`. TypeScript no se queja: el código está bien formado. No te dice si el JSON sirve. Al otro día el listado ya está leyendo `ad.f\_title`.

Eso no rompe el build. El fetch funcionó. Lo que pasó es que el contrato de ellos ya es el tuyo. Si mañana cambian `f\_title` por `title`, el request sigue en verde. Se te queda un blank tres pantallas más abajo, y el que está mirando el componente no tiene por qué saber que el nombre nació afuera.

Ahí uso Zod. Escribes cómo llega el objeto y cómo quieres que salga: entra `f\_title`, sale `title`. El fetch te dice si la red funcionó. Zod te dice si ese JSON sirve para lo que estás construyendo.

Y que quede claro: una API vieja con prefijos `f\_` y el precio a veces string y a veces número es normal. Nació para otra cosa y nadie la reescribe. El criterio mío es no llevarme ese dialecto al frontend, a los hooks, a tres pantallas distintas. Por eso parseo en el server, en el mismo sitio donde hablo con ellos. Valido, renombro, y devuelvo el tipo que el resto del código sí entiende.

## Un marketplace y unas APIs que nadie iba a reescribir

En 2024 tuve la oportunidad de participar en el desarrollo inicial de un marketplace de anuncios. La web era nueva. Las APIs no. Un dump, el JSON tal cual llegaba, se veía más o menos así:

```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"
}
```

Mira ese dump un segundo. `f\_price` es un string. `usdprice` es un número. `userid` y `categoryname` van pegados. Nadie iba a reescribir esas APIs. Tampoco íbamos a copiar eso al código de las pantallas.

En el server armamos el schema de Zod: listaba lo que llegaba, convertía el precio, devolvía `title`, `price`, `user`. El frontend importaba `Ad`. Nunca leyó `f\_title`.

El schema no estaba en el mismo archivo que el fetch. Los types en un package, el HTTP en otro: dos carpetas del server, ninguna del frontend. El endpoint parseaba y devolvía `Ad`. Si el listado no coincidía, `.parse()` tiraba un error. El frontend no recibía medio objeto.

Eso limpió las pantallas. No limpió todo. Los filtros y el orden seguían mandando `f\_price` y `f\_added` en la URL, porque “la API lo pide así”. El criterio vale también ahí: si no cortas el nombre feo en el endpoint, se te queda en la barra del navegador.

La historia de ese proyecto, sin los pasos, está en [El frontend no tenía por qué leer f\_title](https://www.enderpuentes.com/case-studies/el-frontend-no-tenia-por-que-hablar-como-el-erp).

Después me volvió a pasar, en otro proyecto. Las columnas de la base de datos daban vergüenza: abreviaciones, idioma mezclado, una mayúscula a mitad de nombre. No íbamos a la base de datos directo. Había un conector HTTP: le pedías la consulta y te devolvía JSON con esos nombres. Hicimos lo mismo. Schema en el server, respuesta parseada, un objeto que se puede decir en voz alta. El frontend no vio `Fch\_Ult\_Act`. Si el nombre no se entiende, cada review es una traducción. Es el mismo criterio que en la [convención de nombres para triggers](https://www.enderpuentes.com/notes/data-engineering/postgres-trigger-naming-convention), nada más que aquí el “nombre” es el objeto que sale del server.

El mismo corte te sirve cuando contratas facturación, una billetera, tracking. Casi nunca traen tu estándar. El schema está para que tu codebase no herede el de ellos.

## Cómo lo implemento

El fetch te trae un JSON. Si en ese momento le pones un tipo (“esto ya es un `Ad`”), TypeScript se lo cree. No mira si de verdad llegó `f\_title`. Por eso lo dejo como `unknown`: todavía no le pongo forma. Zod es el que la confirma. Con eso claro, el resto es armar el archivo.

### 1. El archivo que habla con la API es el que traduce

Dos archivos. Uno pide los datos. Otro pinta el listado. El segundo nunca lee `f\_title`. Si mañana cambias de proveedor de facturación, cambias el primero. El que pinta no tiene que enterarse.

```
listings/get-ad.ts     ← traduce: schema + fetch
components/ad-list.tsx ← pinta: importa Ad, no el JSON
```

Esto, en el componente, es lo que no quiero. La pantalla habla con la API y se queda con `f\_title`:

```typescript
const payload = await fetch(`/ads/${id}`).then((r) => r.json());
setTitle(payload.f_title);
```

En el marketplace el schema y el fetch estaban en dos carpetas del server. Funciona, si nadie exporta el JSON crudo. Lo que dejo aquí es más estricto: mismo archivo. Ya me pasó que alguien importe las claves feas “solo para el filtro”.

### 2. Escribir cómo llega y cómo quieres que salga

Lista las claves de ellos (`f\_title`, `userid`). En el `transform`, el objeto que tu app ya usa: `title`, `userId`, un id que se llama `id`. Si el precio a veces no viene, lo marcas `.optional()` del lado feo y le pones un default del lado tuyo. No adivines el precio en el componente.

```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,
    // ?? trata igual null (usdprice) y undefined (f_price ausente)
    price: Number(row.f_price ?? row.usdprice ?? 0),
    userId: row.userid,
  }));

type ListingAdInput = z.input<typeof listingAdSchema>;
type Ad = z.infer<typeof listingAdSchema>;
```

Las dos líneas de abajo: `ListingAdInput` es lo que llega (`f\_title`). `Ad` es lo que sale (`title`). El `transform` es ese paso. El componente importa `Ad`. Lo primero no.

`z.object` tira las claves que no listaste. Si el listado agrega algo, no entra a `Ad` hasta que lo agregas tú. No se te perdieron datos: no los pediste.

### 3. Preguntar con `safeParse` y dejar rastro si no sirve

Hay dos maneras de preguntarle a Zod si el JSON sirve. `.parse()` lanza un error si no coincide, y ahí muere el request: nadie recibe medio objeto, pero en el log no queda un texto que puedas buscar. `safeParse` intenta y te responde si salió bien, sin lanzar nada. Yo uso `safeParse` y devuelvo una de dos cosas: el `Ad`, o un código buscable en los logs (`listing\_ad\_shape\_changed`), con `parsed.error.issues` para ver qué campos se movieron. En el marketplace, para ser honesto, usamos `.parse()`. Esta versión es la que dejaría hoy.

```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 };
}
```

Cada respuesta de afuera pasa por ahí. Y lo que no hago es `as Ad`: es mentirle a TypeScript. Esto compila y no te avisa el día que cambien `f\_title`:

```typescript
const ad = (await res.json()) as Ad;
```

### 4. El listado solo ve `Ad`

Ese archivo exporta `getAd` y el tipo `Ad`. El frontend no importa `ListingAdInput` (las claves feas). Si un hook pide el JSON crudo “por si acaso”, el por si acaso ya ganó. Si un filtro tiene que mandar `f\_price` porque la API lo pide, ese nombre se queda donde haces el fetch. No en la pantalla. Y si puedes, tampoco en la URL.

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

El listado consume eso. Lee `title`, no `f\_title`:

```typescript
const result = await getAd(id);

if (result.success) {
  result.data.title;
}
```

El sort, si la API lo pide así, se arma donde haces el fetch:

```typescript
url.searchParams.set('sort', 'f_price');
```

### 5. Si llega un campo nuevo, lo agregas tú

Llegó `f\_addr` y tu schema no lo tiene: no existe en `Ad`. El día que lo necesites, lo agregas a lo que llega y al `transform`. Hasta entonces, no está. Ellos pueden cambiar la API en silencio. Tú no.

```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 ?? '',
  }));
```

## Ahora el que copia el JSON puede ser un agente

Cuando armamos el adapter del marketplace, esto no se lo pedíamos a un agente (el modelo escribiendo solo, no completándote la línea). Lo escribíamos nosotros. Hoy es más fácil que se cuele. Le pides que integre facturación, una billetera, tracking, y el camino corto es pegar el JSON que llegó. Compila. El frontend ya habla como ellos.

Si el repo ya tiene el schema junto al fetch, una rule y una skill, el agente copia eso. Se adapta a la API de afuera. El frontend no. Es el mismo vicio que en [Anti-Cliché](https://www.enderpuentes.com/notes/ai-engineering/antes-tardaba-mas-arreglando-que-escribiendo): el modelo te deja un dialecto que no es de este repo, salvo que le hayas dicho dónde está el borde. En [.agents](https://www.enderpuentes.com/notes/ai-engineering/agents-centralized-rules-and-skills-for-any-ai-editor) eso vive como rules y skills, no como un comentario que alguien tiene que acordarse de pegar.

## Referencia rápida

- El JSON de afuera entra como `unknown`. Zod confirma la forma con `safeParse`.
- `z.input` son las claves de ellos (`f\_title`, `userid`). `z.infer` es tu objeto (`title`, `userId`). El frontend solo importa el segundo.
- Clave que no está en el schema no entra a `Ad`.
- Si el parse falla, devuelve un código buscable (`listing\_ad\_shape\_changed`) y revisa `parsed.error.issues`.
- Nada de `as Ad`, `.passthrough()`, ni traducir en la pantalla. Si el filtro manda `f\_price`, que se quede en el fetch.
