Cómo tipar correctamente una API REST en TypeScript

tipar-api-rest-typescript

¿Te fiarías de un mensaje de WhatsApp para pagar una factura importante? No. Entonces, ¿por qué confías en que la red te devuelva exactamente el JSON que tu UI necesita?

Poca gente lo dice tan claro: la red es hostil. Los backends cambian, las APIs se versionan mal y los datos llegan raros. TypeScript te da sensaciones de seguridad en el editor. Fuera de la compilación, la realidad es JavaScript indiferente. Tipar una API REST no es cuestión de estética. Es la barrera que separa una app que sobrevive a la producción de un desastre nocturno.

Voy a contarte cómo tipar correctamente una API REST en TypeScript sin postureo. Con ejemplos prácticos, decisiones arquitectónicas y la única verdad que importa: que tu app no rompa en prod por culpa de un campo faltante.

Primera regla: el contrato no es el código. Es la promesa. Y debe ser verificable.

Resumen rápido (lectores con prisa)

Qué es: Validar y tipar el JSON recibido de la red en tiempo de ejecución usando esquemas (por ejemplo Zod) junto a tipos TypeScript.

Cuándo usarlo: Siempre en boundaries de red; imprescindible para APIs externas o equipos distintos.

Por qué importa: Previene errores silenciosos en producción y permite estrategias de error diferenciadas.

Cómo funciona: Fetch/axios → recibir unknown → parse con Zod (o similar) → mapear a modelos internos.

¿Por qué los tipos por sí solos no bastan?

TypeScript vive en tiempo de compilación. Cuando haces fetch o llamas a Axios, recibes JSON en tiempo de ejecución. TypeScript no inspecciona la red. Usar as T es firmar un cheque sin fondos: le estás diciendo al compilador “confía en mí”, pero nadie te va a confiar cuando el objeto tenga un null donde esperabas string.

Eso genera errores silenciosos: undefined que se propaga, componentes que se caen sin stack trace claro, tests que pasan porque los mocks también mienten. La alternativa no es renunciar a TypeScript. Es complementarlo con validación runtime donde pinte.

Paso 1 — Define el contrato: DTO claro y aislado

Haz DTOs en su propio archivo. Separa lo que viene de la red de tus modelos internos.

Ejemplo:

// api/types.ts
export interface UserResponse {
  id: string;
  email: string;
  full_name: string; // nota: formato snake_case típico de backend
  created_at: string;
}

export interface ApiError {
  statusCode: number;
  message: string;
  errorCode?: string;
}

¿Por qué aislarlo?

Porque si el backend cambia full_namefullName, no quieres que todo tu frontend explote. Cambias la capa de red y mapping. Punto.

Paso 2 — Wrappers: encapsula fetch/axios

No llames fetch o axios.get en mitad del componente. Haz un wrapper que:

  • haga la petición,
  • valide el payload,
  • normalice nombres,
  • lance errores tipados.

Con fetch:

async function fetchTyped(url: string, opts?: RequestInit): Promise {
  const res = await fetch(url, opts);
  const text = await res.text();
  let json;
  try {
    json = text ? JSON.parse(text) : {};
  } catch {
    throw new Error(`Invalid JSON from ${url}`);
  }
  if (!res.ok) throw Object.assign(new Error('HTTP error'), { status: res.status, body: json });
  return json as T; // todavía una aserción: complementa con validación runtime
}

Con Axios es más limpio:

import axios from 'axios';
async function get(url: string) {
  const res = await axios.get(url);
  return res.data;
}

Pero cuidado: Axios con genéricos es cómodo, no mágico. Sigue siendo una promesa de que la red devolverá lo que dijiste.

Paso 3 — Validación runtime: Zod o similar

Aquí se separan los novatos de los equipos que sobreviven. Zod no es moda; es la última capa de defensa. Define un esquema, parsea el JSON y obtienes tipado estático y validación a la vez.

Ejemplo con Zod:

import { z } from 'zod';

const UserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  full_name: z.string(),
  created_at: z.string(), // luego puedes transformar a Date si quieres
});

type UserResponse = z.infer<typeof UserSchema>;

async function fetchUser(id: string) {
  const raw = await fetchTyped<unknown>(`/api/users/${id}`);
  return UserSchema.parse(raw); // lanza un error si no coincide
}

Ventajas:

  • Detectas inmediatamente datos corruptos.
  • Los errores llegan con contexto (qué llave falló y por qué).
  • Puedes transformar created_at a Date de forma declarativa.

Paso 4 — Manejo de errores tipados

No uses catch (e) { console.error(e) }. En TS moderno el error es unknown. Te obliga a respirar antes de leer.

Con Axios:

try {
  const u = await get<UserResponse>('/api/users/1');
} catch (err) {
  if (axios.isAxiosError(err)) {
    const server: ApiError | undefined = err.response?.data;
    console.error('Server:', server?.message ?? err.message);
  } else if (err instanceof z.ZodError) {
    console.error('Validation failed:', err.errors);
  } else if (err instanceof Error) {
    console.error('Network or runtime:', err.message);
  } else {
    console.error('Unknown error', err);
  }
}

¿Por qué esto importa?

Porque no es lo mismo que falles por validación (mal payload) que por HTTP 500 o por timeout. Cada caso requiere una estrategia distinta: retry, fallback UI, mostrar mensaje al usuario, reportar a Sentry con tags distintos.

Paso 5 — Normaliza y mapea en la capa de red

No dejes snake_case pululando por la app. Tradúcelo en la capa de consumo.

function mapUser(api: UserResponse) {
  return {
    id: api.id,
    email: api.email,
    fullName: api.full_name,
    createdAt: new Date(api.created_at),
  };
}

Así tus modelos internos usan camelCase y Date real, no strings sucios.

Decisiones arquitectónicas: cuándo validar y cuánto validar

  • Si controlas backend y frontend (monorepo o contrato firme): validación runtime puede ser ligera o incluso obviada en puntos internos. Pero en los boundaries externos, sigue validando.
  • Si consumes APIs públicas o equipos distintos: valida TODO. Sin excusas.
  • Campos opcionales: define qué hacer cuando faltan. ¿Default? ¿Error? No improvises en el handler.

Performance y coste: ¿La validación runtime es lenta?

Sí, agrega CPU. No, no es un killer. Para la mayoría de apps es insignificante comparado con network. Si tu app maneja miles de respuestas por segundo, shardea validaciones o valida en capas: sólo validar payloads que entren a la lógica crítica.

Testing: no te olvides de tests de integración

Mocks unitarios son útiles, pero añade tests e2e que disparen la API real (o un sandbox) y verifiquen que las validaciones y mappings no rompen. Testear tipado estático no te salva si la red cambia; testear contra endpoint sí.

Checklist práctico (aplicable hoy)

  1. Centraliza DTOs en src/api/types.ts.
  2. Implementa un http wrapper (fetch/axios) que devuelva unknown.
  3. Define Zod schemas para cada DTO.
  4. Parsea el unknown con los schemas antes de castear a tipos.
  5. Mappea a modelos internos (camelCase, Date).
  6. Trata y tipa errores (Axios ZodError, Error genérico).
  7. Agrega tests e2e para validar contratos.
  8. Logea y reporta fallos con contexto (payload, endpoint, user id).

Antipatrones que debes romper hoy

  • Usar as T en todo el código. Es la tapa del inodoro para ignorar problemas.
  • Confiar en que “el backend no cambia porque somos amigos”.
  • No versionar tus contratos. Versiona APIs y mantén backwards compatibility.
  • Manejar errores con any. Eso borra información útil para debugging.

Metáfora que funciona: el contrato es la arquitectura del puente

Tu frontend es el tráfico. El backend es el río. Si el puente (contrato) no es firme y verificado, los coches caen al agua. Los tipos son el plano; la validación runtime es el inspector que revisa la soldadura antes de poner el primer coche encima.

Mini-guía de migración si tienes legado

  • Prioriza endpoints críticos: pagos, auth, guardar datos de usuario. Valida primero allí.
  • Añade schemas Zod incrementales; empieza por parse y logea sin bloquear; luego, cuando el tráfico confirme estabilidad, cambia a bloquear y fallar fast.
  • Automatiza alerts: si un endpoint empieza a fallar por validación, dispara una tarea de mesa de ayuda y un PR para el backend. No lo ignores.

Y ahora, lo que pocas hojas blancas te dirán: la cultura importa

La técnica sola no arregla nada. Si los desarrolladores creen que validar es “trabajo extra” y no “seguridad mínima”, la herramienta se convertirá en una carpeta más del repo. Hazlo obligatorio en el CI:

  • Test de contractos: correr zod.parse en un job.
  • Rechazar PRs si los contratos no están actualizados.
  • Mantén el mapping y los schemas en la misma PR que cambia el backend si controlas ambos.

¿Quieres algo listo para copiar y pegar?

Tengo un template completo: wrapper http (fetch + manejo de errores), ejemplo de schema Zod, mapping y un job de GitHub Actions que valida contratos en CI. Lo dejo preparado para TypeScript + React (Vite) o Next.js.

Haz esto ahora:

  1. Crea src/api/
  2. Pega el wrapper y el schema Zod del template.
  3. Añade ci/validate-contracts.yml a tu pipeline.
  4. Corre pnpm test:contracts en tu CI.

¿Te lo mando ya?

Responde “Mándame el template” y te paso:

  • El wrapper http.ts listo.
  • Un user.schema.ts con Zod y z.infer.
  • Un ejemplo de fetchUser + mapping + test e2e.
  • Un workflow de GitHub Actions que bloquea merges si falla la validación.

No es sexy. Es aburrido. Pero es lo que evita que pases la noche arreglando producción porque un created_at vino null.
Esto no acaba aquí.

FAQ

¿Por qué no son suficientes los tipos de TypeScript?

Porque TypeScript opera en tiempo de compilación. Los datos de la red llegan en tiempo de ejecución y pueden diferir del contrato. Validar runtime evita errores silenciosos y fallos en producción.

¿Qué es Zod y por qué usarlo?

Zod es una biblioteca de esquemas para validación y parsing en runtime. Proporciona validación declarativa, errores con contexto y permite inferir tipos TypeScript desde el esquema.

¿Dónde debo validar: en backend o frontend?

Valida en ambos. El backend debe proteger su dominio; el frontend debe validar boundaries externos y usuarios malformados. Si controlas ambos, puedes relajar validaciones internas, pero nunca las boundaries públicas.

¿No hará lenta la app la validación runtime?

Añade CPU, sí, pero para la mayoría de aplicaciones el coste es insignificante frente a la latencia de red. Para sistemas de alto rendimiento, valida selectivamente o en capas.

¿Cómo manejar campos opcionales o faltantes?

Decide una política: default, error o fallback. Documenta la decisión y aplica la política consistentemente en la capa de red. No improvises en handlers dispersos.

¿Qué patrones debo evitar hoy mismo?

Evita usar as T indiscriminadamente, confiar en que el backend no cambiará y manejar errores con any. También evita no versionar contratos y no tener tests contra endpoints reales.

Tiempo estimado de lectura: 6 min

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *