Diseño de API contract-first con OpenAPI y TypeScript
Deja de permitir que tu especificación se desvíe del código: define primero el contrato, genera los tipos para ambos lados y que el compilador detecte fallos.

La mayoría de los equipos escriben primero la API y la documentan después. La especificación OpenAPI se convierte en una instantánea de la realidad hecha con buena voluntad: precisa en el momento de escribirla, obsoleta con el siguiente PR. Los clientes salen perjudicados. La especificación se convierte en ruido en el que nadie confía.
El desarrollo contract-first invierte esto: define la forma de la API en YAML antes de escribir un solo handler, y luego genera los tipos a partir de ese contrato. Tanto el servidor como el cliente trabajan contra la misma fuente de verdad, y el compilador te avisa cuando se han desviado.
Por qué el enfoque code-first se desmorona
La desviación es sutil al principio. Un campo se renombra en el handler pero no en la especificación. Una propiedad de respuesta se vuelve opcional pero el cliente asume que siempre está presente. Aparece un nuevo parámetro de consulta en el código que nadie documentó. Después de seis meses, la especificación es decorativa.
// ❌ Code-first: spec and implementation evolve independently
// spec says: { userId: string, name: string }
// handler returns:
return {
id: user.id, // renamed — spec still says userId
fullName: user.name, // renamed — spec still says name
role: user.role, // undocumented addition
};
// ✅ Contract-first: types derived from the spec — renaming the spec breaks the build
return {
userId: user.id,
name: user.name,
} satisfies paths["/users/{id}"]["get"]["responses"]["200"]["content"]["application/json"];La segunda versión no requiere disciplina: requiere que el compilador haga cumplir el contrato.
Definiendo el contrato en OpenAPI
Empieza con la especificación. Escríbela antes que cualquier código de handler. Mantenla en la raíz del repositorio, donde sea visible y esté bajo control de versiones junto al código que describe.
# openapi.yaml
openapi: "3.1.0"
info:
title: Users API
version: "1.0.0"
paths:
/users/{id}:
get:
operationId: getUserById
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: User found
content:
application/json:
schema:
$ref: "#/components/schemas/UserProfile"
"404":
description: User not found
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
components:
schemas:
UserProfile:
type: object
required: [id, name, email, createdAt]
properties:
id:
type: string
format: uuid
name:
type: string
email:
type: string
format: email
createdAt:
type: string
format: date-time
ErrorResponse:
type: object
required: [code, message]
properties:
code:
type: string
message:
type: stringEste YAML es la definición canónica. Todo lo que sigue se deriva de ella, y no al revés.
Generando tipos de TypeScript a partir de la especificación
openapi-typescript convierte tu YAML en un módulo de TypeScript completamente tipado. Sin interfaces escritas a mano, sin errores de transcripción.
# Install once
npm install -D openapi-typescript
# Regenerate after every spec change
npx openapi-typescript ./openapi.yaml -o ./src/types/api.d.tsEl resultado es un tipo paths que refleja toda la estructura de tu especificación. Cada ruta, cada método, cada cuerpo de petición y respuesta está tipado, incluidos los campos opcionales, las uniones discriminadas y los esquemas profundamente anidados.
Haz commit del api.d.ts generado en tu repositorio y regenéralo en CI. Un PR que cambia la especificación sin regenerar los tipos fallará la verificación de tipos, detectando la desviación antes de que se fusione.
Tipando el handler del servidor
Con los tipos generados, el tipo de retorno de un handler se deriva de la especificación en lugar de declararse a mano. Si la especificación dice que un campo es obligatorio y tú lo omites, la compilación falla.
import type { paths } from "@/types/api";
import type { RequestHandler } from "express";
type GetUserResponse =
paths["/users/{id}"]["get"]["responses"]["200"]["content"]["application/json"];
type GetUserError =
paths["/users/{id}"]["get"]["responses"]["404"]["content"]["application/json"];
export const getUserById: RequestHandler<{ id: string }> = async (req, res) => {
const user = await userRepository.findById(req.params.id);
if (!user) {
const body: GetUserError = {
code: "USER_NOT_FOUND",
message: `No user with id ${req.params.id}`,
};
return res.status(404).json(body);
}
const body: GetUserResponse = {
id: user.id,
name: user.name,
email: user.email,
createdAt: user.createdAt.toISOString(),
};
res.status(200).json(body);
};Renombra name a fullName en la especificación y este handler dejará de compilar hasta que se actualice. Ese es el ciclo de retroalimentación que el desarrollo code-first nunca puede darte.
Construyendo un cliente con seguridad de tipos
El mismo tipo paths generado funciona en el cliente. Sin archivos de interfaces separados, sin un tipo UserProfile mantenido manualmente que se desincroniza entre paquetes.
import type { paths } from "@/types/api";
type UserProfile =
paths["/users/{id}"]["get"]["responses"]["200"]["content"]["application/json"];
async function getUser(id: string): Promise<UserProfile> {
const response = await fetch(`/api/users/${id}`, {
headers: { Accept: "application/json" },
});
if (response.status === 404) {
throw new NotFoundError(`User ${id} does not exist`);
}
if (!response.ok) {
throw new ApiError(`Unexpected status ${response.status}`);
}
return response.json() as Promise<UserProfile>;
}Para garantías más estrictas, openapi-fetch envuelve la API nativa de fetch con verificación de tipos completa en cada parámetro, encabezado y respuesta, incluido qué códigos de estado son válidos para una operación dada.
Validando en tiempo de ejecución
Los tipos de TypeScript desaparecen en tiempo de ejecución. Un cliente con una versión obsoleta de la especificación, un proxy mal configurado o un bug en un servicio upstream aún pueden entregar datos malformados. Añade validación en tiempo de ejecución en el límite.
import { z } from "zod";
// Mirror the OpenAPI schema with a Zod schema — or use openapi-zod-client to generate it
const userProfileSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
email: z.string().email(),
createdAt: z.string().datetime(),
});
export function parseUserProfile(raw: unknown) {
const result = userProfileSchema.safeParse(raw);
if (!result.success) {
throw new ValidationError(
"User profile response did not match contract",
result.error.issues,
);
}
return result.data;
}Herramientas como openapi-zod-client y zod-openapi pueden generar esquemas de Zod directamente a partir de tu especificación OpenAPI, de modo que la validación en tiempo de ejecución se mantiene sincronizada con el contrato sin un paso de traducción manual.
Aplicando el flujo de trabajo en CI
El enfoque contract-first solo se sostiene si la especificación se actualiza siempre antes que el código. Aplícalo de forma estructural para que no sea una convención que se erosiona bajo la presión de los plazos.
# .github/workflows/api-contract.yml
name: API Contract Check
on: [push, pull_request]
jobs:
contract:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- name: Lint OpenAPI spec
run: npx @redocly/cli lint openapi.yaml
- name: Regenerate types
run: npx openapi-typescript ./openapi.yaml -o ./src/types/api.d.ts
- name: Fail if types are out of sync
run: |
git diff --exit-code src/types/api.d.ts || \
(echo "Regenerate types locally: npx openapi-typescript ./openapi.yaml -o ./src/types/api.d.ts" && exit 1)
- name: Type check
run: npx tsc --noEmitUn PR que actualiza la especificación sin regenerar los tipos falla en la verificación de diff. Un handler que rompe el contrato generado falla en tsc --noEmit. Ninguno de los dos requiere que un revisor lo detecte, lo que significa que ambos se detectan en cada PR, no solo en aquellos en los que alguien recuerda mirar.
Conclusiones clave
- Las especificaciones code-first se desvían por diseño: la documentación escrita después de los hechos refleja la intención, no la realidad. Define el contrato antes de escribir los handlers.
- Genera, no transcribas: las interfaces de TypeScript escritas a mano para los tipos de API son un pasivo de mantenimiento. Derívalas de una única fuente de verdad.
- Los tipos son solo de tiempo de compilación: combina los tipos generados con validación en tiempo de ejecución mediante Zod en los límites de confianza; no asumas que el código bien tipado está a salvo de entradas malformadas.
- Una especificación, dos consumidores: el mismo tipo
pathssirve a los handlers del servidor y a los wrappers de fetch del cliente, garantizando que ambos lados hablan el mismo idioma. - Haz la desviación imposible, no solo prohibida: un pipeline de CI que regenera los tipos y ejecuta
tscelimina la necesidad de disciplina al convertir el incumplimiento en un fallo de compilación.


