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.

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, 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.

text
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é: el modelo te deja un dialecto que no es de este repo, salvo que le hayas dicho dónde está el borde. En .agents 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.

Más notas sobre Arquitectura de software.