Saltar al contenido

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.

5 min de lectura
Especificación OpenAPI en YAML junto a los tipos de TypeScript generados, mostrando el diseño de API contract-first

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.

tstypescript
// ❌ 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.

ymlyaml
# 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: string

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

shbash
# Install once
npm install -D openapi-typescript
 
# Regenerate after every spec change
npx openapi-typescript ./openapi.yaml -o ./src/types/api.d.ts

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

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

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

tstypescript
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;
}
i

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.

ymlyaml
# .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 --noEmit

Un 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

  1. 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.
  2. 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.
  3. 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.
  4. Una especificación, dos consumidores: el mismo tipo paths sirve a los handlers del servidor y a los wrappers de fetch del cliente, garantizando que ambos lados hablan el mismo idioma.
  5. Haz la desviación imposible, no solo prohibida: un pipeline de CI que regenera los tipos y ejecuta tsc elimina la necesidad de disciplina al convertir el incumplimiento en un fallo de compilación.
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX