Cómo tipar correctamente una API REST en 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_name → fullName, 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_ata 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)
- Centraliza DTOs en
src/api/types.ts. - Implementa un
httpwrapper (fetch/axios) que devuelvaunknown. - Define Zod schemas para cada DTO.
- Parsea el
unknowncon los schemas antes de castear a tipos. - Mappea a modelos internos (camelCase, Date).
- Trata y tipa errores (Axios ZodError, Error genérico).
- Agrega tests e2e para validar contratos.
- Logea y reporta fallos con contexto (payload, endpoint, user id).
Antipatrones que debes romper hoy
- Usar
as Ten 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.parseen 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:
- Crea
src/api/ - Pega el wrapper y el schema Zod del template.
- Añade
ci/validate-contracts.ymla tu pipeline. - Corre
pnpm test:contractsen tu CI.
¿Te lo mando ya?
Responde “Mándame el template” y te paso:
- El wrapper
http.tslisto. - Un
user.schema.tscon Zod yz.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?
- ¿Qué es Zod y por qué usarlo?
- ¿Dónde debo validar: en backend o frontend?
- ¿No hará lenta la app la validación runtime?
- ¿Cómo manejar campos opcionales o faltantes?
- ¿Qué patrones debo evitar hoy mismo?
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.
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.
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.
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.
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.
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
