tutoriales.com

Tipado Preciso de APIs REST con TypeScript: Generación de Cliente y Schemas

Este tutorial te guiará a través del proceso de tipado de APIs REST utilizando TypeScript, aprovechando las especificaciones OpenAPI/Swagger. Aprenderás a generar automáticamente tipos y clientes de API, mejorando la seguridad, la robustez y la eficiencia de tu código.

Intermedio20 min de lectura9 views
Reportar error

¡Hola, desarrolladores! 👋 En el mundo moderno del desarrollo web, interactuar con APIs REST es una tarea cotidiana. Sin embargo, ¿cuántas veces hemos luchado con errores de tipado, propiedades undefined, o simplemente hemos perdido horas debugueando problemas de comunicación entre nuestro frontend (o backend) y un servicio RESTful? TypeScript nos ofrece una solución poderosa para estos desafíos, y en este tutorial, exploraremos cómo podemos llevar el tipado de nuestras interacciones con APIs a un nivel completamente nuevo utilizando herramientas estándar de la industria.

El objetivo es simple: eliminar la incertidumbre y los errores de tipo al consumir APIs REST. Para lograrlo, nos apoyaremos en la especificación OpenAPI (anteriormente conocida como Swagger) y en herramientas que nos permiten generar automáticamente tipos de TypeScript y clientes de API a partir de esta especificación. Esto no solo nos ahorra tiempo, sino que también nos proporciona una experiencia de desarrollo mucho más segura y agradable.


¿Por qué tipar APIs REST con TypeScript? 🤔

TypeScript es famoso por añadir seguridad de tipos a JavaScript. Cuando trabajamos con APIs, los datos que recibimos son inherentemente dinámicos y, a menudo, carecen de tipado estricto en el lado del cliente (si no se define explícitamente). Esto puede llevar a una serie de problemas:

  • Errores en tiempo de ejecución: Acceder a propiedades que no existen o que tienen un tipo inesperado puede causar TypeError o undefined en tu aplicación.
  • Refactorización difícil: Cambios en la API pueden romper silenciosamente tu código sin que el compilador te avise.
  • Falta de autocompletado: El IDE no puede sugerir propiedades y métodos, ralentizando el desarrollo y aumentando la probabilidad de errores tipográficos.
  • Mantenimiento costoso: Depurar problemas relacionados con los datos de la API se vuelve una tarea tediosa y propensa a errores.

Al tipar nuestras interacciones con APIs, transformamos un contrato de datos implícito en uno explícito y verificable por el compilador de TypeScript. Esto significa:

  • ✅ Detección temprana de errores durante la compilación, no en tiempo de ejecución.
  • ✅ Autocompletado y validación de tipos en tu editor (IDE), aumentando la productividad.
  • ✅ Refactorización segura: los cambios en la API se reflejarán en errores de compilación claros.
  • ✅ Código más legible y fácil de mantener.
💡 Consejo: Piensa en el tipado de APIs como la creación de un "contrato" formal entre tu aplicación y el servidor. TypeScript se encarga de que ambas partes cumplan ese contrato en tiempo de desarrollo.

El Rol de OpenAPI (Swagger) 📖

Antes de sumergirnos en el código, es crucial entender qué es OpenAPI. OpenAPI es una especificación estándar y agnóstica al lenguaje para describir APIs RESTful. Esencialmente, es una forma de documentar tu API de manera que tanto humanos como máquinas puedan entenderla.

Una especificación OpenAPI describe:

  • Endpoints disponibles: /users, /products/{id}, etc.
  • Operaciones HTTP: GET, POST, PUT, DELETE.
  • Parámetros: (query, path, header, body) y sus tipos.
  • Estructuras de datos (schemas): Cómo lucen los objetos que se envían y reciben (ej. User: { id: number, name: string }).
  • Respuestas: Qué se espera recibir de cada endpoint para diferentes códigos de estado (200 OK, 404 Not Found, etc.).

Esta descripción se suele almacenar en un archivo YAML o JSON. Si tu equipo backend ya genera una especificación OpenAPI, ¡estás de suerte! Si no, es un excelente momento para abogar por su implementación, ya que facilita enormemente la integración.

Especificación OpenAPI Generador de Cliente (Herramienta) Cliente TypeScript (Tipos y Métodos) Desarrollador Interactúa

Herramientas de Generación: openapi-typescript y openapi-fetch 🛠️

Existen varias herramientas para generar tipos y clientes de API a partir de una especificación OpenAPI. En este tutorial, nos centraremos en dos herramientas modernas y eficientes de la suite openapi-ts:

  1. openapi-typescript: Genera interfaces y tipos de TypeScript directamente desde tu archivo OpenAPI. Es perfecto si solo necesitas los tipos de datos.
  2. openapi-fetch: Va un paso más allá. Genera un cliente de API completo y totalmente tipado basado en fetch que utiliza los tipos generados por openapi-typescript. Esto te proporciona no solo los tipos de los datos, sino también los tipos para las funciones de tus llamadas API (parámetros, cuerpo, respuestas).
🔥 Importante: Asegúrate de que la especificación OpenAPI de tu API esté actualizada y sea precisa. La calidad de los tipos generados depende directamente de la calidad de esta especificación.

Prerrequisitos

Antes de empezar, asegúrate de tener:

  • Node.js instalado (versión 16 o superior).
  • npm o yarn.
  • Un proyecto TypeScript existente (o crea uno nuevo).
  • Una URL o archivo local de una especificación OpenAPI (YAML/JSON).

Para este tutorial, usaremos un ejemplo de especificación OpenAPI para una API de todos (tareas pendientes). Puedes encontrar ejemplos de especificaciones OpenAPI fácilmente en línea, o simular una si no tienes una real.


Paso 1: Configuración del Proyecto y Obtención de la Especificación ⚙️

Primero, crearemos un nuevo proyecto TypeScript y obtendremos nuestra especificación OpenAPI.

1.1. Inicializar un Proyecto TypeScript

Si ya tienes un proyecto, puedes omitir este paso.

mkdir todo-api-client
cd todo-api-client
npm init -y
npm install typescript @types/node
npx tsc --init

Esto creará un package.json y un tsconfig.json básico.

1.2. Obtener la Especificación OpenAPI

Necesitamos un archivo openapi.yaml (o openapi.json). Para este ejemplo, usaremos una especificación simplificada para una API de todos. Guarda el siguiente contenido como openapi.yaml en la raíz de tu proyecto:

openapi: 3.0.0
info:
  title: Todo API
  version: 1.0.0
  description: A simple API for managing todo items
servers:
  - url: http://localhost:3000/api/v1
paths:
  /todos:
    get:
      summary: Get all todos
      operationId: getTodos
      responses:
        '200':
          description: A list of todo items.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Todo'
    post:
      summary: Create a new todo
      operationId: createTodo
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NewTodo'
      responses:
        '201':
          description: The created todo item.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Todo'
  /todos/{id}:
    get:
      summary: Get a todo by ID
      operationId: getTodoById
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: A single todo item.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Todo'
        '404':
          description: Todo not found.
    put:
      summary: Update a todo by ID
      operationId: updateTodo
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NewTodo'
      responses:
        '200':
          description: The updated todo item.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Todo'
        '404':
          description: Todo not found.
    delete:
      summary: Delete a todo by ID
      operationId: deleteTodo
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '204':
          description: Todo deleted successfully.
        '404':
          description: Todo not found.

components:
  schemas:
    Todo:
      type: object
      required:
        - id
        - title
        - completed
      properties:
        id:
          type: string
          format: uuid
          description: Unique identifier for the todo item.
        title:
          type: string
          description: The title of the todo item.
        description:
          type: string
          nullable: true
          description: Optional detailed description.
        completed:
          type: boolean
          default: false
          description: Whether the todo item is completed.
    NewTodo:
      type: object
      required:
        - title
      properties:
        title:
          type: string
          description: The title of the todo item.
        description:
          type: string
          nullable: true
          description: Optional detailed description.
        completed:
          type: boolean
          default: false
          description: Whether the todo item is completed.

Paso 2: Generar Tipos con openapi-typescript

openapi-typescript es una herramienta de línea de comandos que toma una especificación OpenAPI y escupe un archivo .d.ts con todos los tipos definidos en tu API. Esto es increíblemente útil incluso si decides escribir tu propio cliente API manual, ya que te proporciona los tipos de datos esenciales.

2.1. Instalación

npm install openapi-typescript

2.2. Ejecución

Ahora, ejecuta el comando para generar los tipos. Es una buena práctica poner los tipos generados en un directorio separado, por ejemplo, src/types/api.d.ts.

npx openapi-typescript openapi.yaml --output src/types/api.d.ts
📌 Nota: Si tu especificación OpenAPI está alojada en una URL (por ejemplo, `https://petstore.swagger.io/v2/swagger.json`), puedes usar esa URL directamente como entrada: `npx openapi-typescript https://petstore.swagger.io/v2/swagger.json --output src/types/api.d.ts`

2.3. Explorando los Tipos Generados

Abre src/types/api.d.ts. Verás algo similar a esto (la salida real será más extensa):

/**
 * This file was auto-generated by openapi-typescript. 
 * Do not make direct changes to the file.
 */

export interface paths {
  "/todos": {
    get: {
      responses: {
        /** @description A list of todo items. */
        200: {
          content: {
            "application/json": components["schemas"]["Todo"][];
          };
        };
      };
    };
    post: {
      requestBody: {
        content: {
          "application/json": components["schemas"]["NewTodo"];
        };
      };
      responses: {
        /** @description The created todo item. */
        201: {
          content: {
            "application/json": components["schemas"]["Todo"];
          };
        };
      };
    };
  };
  "/todos/{id}": {
    get: {
      parameters: {
        path: {
          id: string;
        };
      };
      responses: {
        /** @description A single todo item. */
        200: {
          content: {
            "application/json": components["schemas"]["Todo"];
          };
        };
        /** @description Todo not found. */
        404: unknown;
      };
    };
    // ... more path definitions
  };
}

export interface components {
  schemas: {
    Todo: {
      id: string;
      title: string;
      description?: string | null;
      completed?: boolean;
    };
    NewTodo: {
      title: string;
      description?: string | null;
      completed?: boolean;
    };
  };
}

export interface operations {
  getTodos: {
    responses: {
      200: paths["/todos"]["get"]["responses"][200]["content"]["application/json"];
    };
  };
  createTodo: {
    requestBody: paths["/todos"]["post"]["requestBody"]["content"]["application/json"];
    responses: {
      201: paths["/todos"]["post"]["responses"][201]["content"]["application/json"];
    };
  };
  // ... more operation definitions
}

Observa cómo openapi-typescript ha generado:

  • paths: Tipos para cada ruta y sus métodos HTTP.
  • components['schemas']: Interfaces TypeScript para tus modelos de datos (Todo, NewTodo).
  • operations: Tipos detallados para cada operationId definido en tu OpenAPI, incluyendo los tipos de los parámetros, cuerpos de solicitud y respuestas.
💡 Consejo: Estos tipos son la base para construir un cliente API robusto. Puedes importarlos en cualquier lugar de tu aplicación para asegurar que los datos que manejas se ajusten a la API.

Paso 3: Generar y Usar el Cliente API con openapi-fetch 🚀

Ahora que tenemos los tipos, podemos aprovecharlos para generar un cliente API completamente tipado con openapi-fetch.

3.1. Instalación

npm install openapi-fetch

3.2. Ejecución (Generación del Cliente)

A diferencia de openapi-typescript que solo genera tipos .d.ts, openapi-fetch genera un archivo .ts real que exporta un cliente fetch listo para usar.

Vamos a crear un script para esto. Crea un archivo generate-client.js en la raíz de tu proyecto:

// generate-client.js

const { generate } = require('openapi-fetch');
const path = require('path');
const fs = require('fs');

async function generateApiClient() {
  const outputPath = path.resolve(__dirname, 'src/api-client.ts');

  // Asegúrate de que el directorio de salida exista
  fs.mkdirSync(path.dirname(outputPath), { recursive: true });

  await generate({ 
    input: path.resolve(__dirname, 'openapi.yaml'),
    output: outputPath,
    // Puedes especificar la base de tu URL aquí o al inicializar el cliente
    // baseUrl: 'http://localhost:3000/api/v1', 
  });

  console.log(`Cliente API generado en: ${outputPath}`);
}

generateApiClient();

Ahora, añade un script a tu package.json para ejecutarlo:

{
  "name": "todo-api-client",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "generate:types": "npx openapi-typescript openapi.yaml --output src/types/api.d.ts",
    "generate:client": "node generate-client.js",
    "generate:all": "npm run generate:types && npm run generate:client"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "openapi-fetch": "^1.1.0",
    "openapi-typescript": "^6.2.0",
    "typescript": "^5.2.2"
  },
  "devDependencies": {
    "@types/node": "^20.8.9"
  }
}

Ejecuta el script:

npm run generate:all

Esto creará un archivo src/api-client.ts que contendrá el cliente fetch tipado.

3.3. Explorando el Cliente Generado

El archivo src/api-client.ts exportará una función createClient. Aquí hay una versión simplificada de lo que verías:

// src/api-client.ts (contenido generado simplificado)

import type { paths } from "./types/api"; // Importa tus tipos generados
import createClient from "openapi-fetch";

export const { GET, POST, PUT, DELETE } = createClient<paths>({
  // baseUrl: "http://localhost:3000/api/v1", // Opcional: define aquí la base URL
});

Lo importante aquí es que createClient está parametrizado con paths (tus tipos OpenAPI generados), lo que permite que el cliente sea totalmente consciente de los tipos de tus endpoints.

3.4. Usando el Cliente Tipado en Tu Aplicación

Ahora puedes usar este cliente en tu aplicación. Crea un archivo src/index.ts:

// src/index.ts

// Importa el cliente generado. openapi-fetch ya usa los tipos de api.d.ts internamente.
import createClient from "openapi-fetch";
import type { paths } from "./types/api"; 

// Crea una instancia del cliente con la base URL de tu API
const client = createClient<paths>({ baseUrl: "http://localhost:3000/api/v1" });

async function fetchTodos() {
  try {
    // fetchTodos es una operación GET /todos
    // Nota cómo el IDE ya sugiere `getTodos` y sus parámetros/respuestas están tipados
    const { data, error, response } = await client.GET("/todos", {});

    if (error) {
      console.error("Error fetching todos:", error);
      return;
    }

    console.log("Todos:", data);

    // Ejemplo de un todo específico (si existe)
    if (data && data.length > 0) {
      const firstTodoId = data[0].id;
      const { data: todoData, error: todoError } = await client.GET("/todos/{id}", {
        params: { path: { id: firstTodoId } },
      });

      if (todoError) {
        console.error(`Error fetching todo ${firstTodoId}:`, todoError);
      } else {
        console.log(`First todo (${firstTodoId}):`, todoData);
      }
    }

  } catch (err) {
    console.error("An unexpected error occurred:", err);
  }
}

async function createNewTodo() {
  try {
    const newTodoPayload = {
      title: "Learn OpenAPI and TypeScript",
      description: "Master API typing for robust applications",
      completed: false,
    };

    // createTodo es una operación POST /todos
    const { data, error } = await client.POST("/todos", {
      body: newTodoPayload,
    });

    if (error) {
      console.error("Error creating todo:", error);
      return;
    }

    console.log("Created Todo:", data);

  } catch (err) {
    console.error("An unexpected error occurred during creation:", err);
  }
}

async function updateExistingTodo() {
  try {
    // Primero, obtengamos un todo para actualizar
    const { data: todos, error: fetchError } = await client.GET("/todos", {});
    if (fetchError || !todos || todos.length === 0) {
      console.error("Could not fetch todos to update.", fetchError);
      return;
    }

    const todoToUpdate = todos[0];
    const updatedPayload = { ...todoToUpdate, completed: true };

    const { data, error } = await client.PUT("/todos/{id}", {
      params: { path: { id: todoToUpdate.id } },
      body: updatedPayload,
    });

    if (error) {
      console.error(`Error updating todo ${todoToUpdate.id}:`, error);
      return;
    }

    console.log("Updated Todo:", data);

  } catch (err) {
    console.error("An unexpected error occurred during update:", err);
  }
}

async function deleteExistingTodo() {
  try {
    // Primero, obtengamos un todo para eliminar
    const { data: todos, error: fetchError } = await client.GET("/todos", {});
    if (fetchError || !todos || todos.length === 0) {
      console.error("Could not fetch todos to delete.", fetchError);
      return;
    }

    const todoToDeleteId = todos[0].id;

    const { error } = await client.DELETE("/todos/{id}", {
      params: { path: { id: todoToDeleteId } },
    });

    if (error) {
      console.error(`Error deleting todo ${todoToDeleteId}:`, error);
      return;
    }

    console.log(`Todo ${todoToDeleteId} deleted successfully.`);

  } catch (err) {
    console.error("An unexpected error occurred during deletion:", err);
  }
}

async function main() {
  console.log("--- Fetching Todos ---");
  await fetchTodos();
  console.log("\n--- Creating New Todo ---");
  await createNewTodo();
  console.log("\n--- Updating Existing Todo ---");
  await updateExistingTodo();
  console.log("\n--- Deleting Existing Todo ---");
  await deleteExistingTodo();
}

main();

Para ejecutar este código, compílalo y luego ejecútalo con Node.js:

npx tsc src/index.ts --outDir dist
node dist/index.js
⚠️ Advertencia: Para que este ejemplo funcione completamente, necesitarías un servidor API real que implemente la especificación `openapi.yaml`. Si no tienes uno, las llamadas a la API fallarán con errores de red, pero el *tipado* de tu código seguirá siendo correcto.

Observa la magia del autocompletado y la comprobación de tipos:

  • Al escribir client.GET("/todos", { ... }), TypeScript sabe exactamente qué parámetros puede tomar el segundo argumento (por ejemplo, params: { query: {...} }).
  • La propiedad data de la respuesta ya tiene el tipo components['schemas']['Todo'][] para GET /todos y components['schemas']['Todo'] para GET /todos/{id}.
  • Si intentaras pasar un campo incorrecto en newTodoPayload a client.POST("/todos", { body: newTodoPayload }), TypeScript te alertaría inmediatamente.

Personalización y Opciones Avanzadas 🚀

openapi-fetch y openapi-typescript ofrecen varias opciones para adaptar la generación a tus necesidades.

Opciones de openapi-typescript

  • --prettier-config: Ruta a un archivo Prettier config para formatear la salida.
  • --scalar-types: Mapear tipos OpenAPI específicos a tipos TypeScript personalizados (ej. string:UUID=string).
  • --extract-responses: Extraer solo los tipos de respuesta.
  • --immutable: Generar tipos readonly.

Consulta la documentación de openapi-typescript para más detalles.

Opciones de openapi-fetch

  • baseUrl: Ya lo vimos, establece la URL base de tu API.
  • fetch: Puedes inyectar tu propia función fetch personalizada (útil para node-fetch en entornos Node.js o para añadir interceptores).
  • headers: Establecer cabeceras por defecto para todas las solicitudes.
  • querySerializer: Personalizar cómo se serializan los parámetros de consulta.

Por ejemplo, para añadir un token de autenticación global:

// src/auth-client.ts

import createClient from "openapi-fetch";
import type { paths } from "./types/api";

export const authenticatedClient = createClient<paths>({
  baseUrl: "http://localhost:3000/api/v1",
  headers: {
    Authorization: "Bearer YOUR_AUTH_TOKEN_HERE", // Reemplaza con tu token dinámico
  },
});

// Luego úsalo así:
// const { data, error } = await authenticatedClient.GET("/protected-resource");

Integración en Proyectos Grandes y CI/CD 🏢

En un entorno de desarrollo real, querrás automatizar la generación de estos tipos y clientes.

Flujo de Trabajo Recomendado

  1. Fuente de Verdad: Asegúrate de que la especificación OpenAPI sea la fuente de verdad y se mantenga actualizada por el equipo de backend (o por tu propio proceso si eres full-stack).
  2. Scripts de Generación: Incluye los scripts npm run generate:all (o similar) en tu package.json.
  3. Hooks Git: Considera usar un pre-commit hook (con herramientas como husky) para ejecutar la generación automáticamente antes de cada commit, asegurando que los tipos estén siempre al día con la especificación local.
  4. Integración Continua (CI): Ejecuta la generación como parte de tu pipeline de CI. Si la especificación OpenAPI cambia en el repositorio del backend, tu pipeline de CI puede actualizar y validar automáticamente el cliente tipado en tu proyecto frontend/cliente.
  5. Ignorar Archivos Generados (o no): Decidir si los archivos generados (src/types/api.d.ts y src/api-client.ts) deben ser commiteados o no. Ambas opciones tienen pros y contras:
    • Commitear: Garantiza que todos los desarrolladores tengan los mismos tipos sin necesidad de ejecutar el script manualmente. Es más robusto en CI.
    • No commitear: Reduce el ruido en el control de versiones, pero requiere que cada desarrollador ejecute el script al iniciar el proyecto y cada vez que la especificación cambie. En este caso, añade los scripts de generación a tus postinstall o prestart scripts.
⚠️ Advertencia: Si no commiteas los archivos generados, asegúrate de que tu `tsconfig.json` los incluya en el `include` y tu `package.json` tenga scripts que los generen de manera fiable en cualquier entorno.

Ventajas y Desventajas de este Enfoque 🎯

Como cualquier tecnología, este enfoque tiene sus puntos fuertes y débiles.

Ventajas

  • Robustez: Errores de tipo detectados en compilación, no en ejecución.
  • Productividad: Autocompletado completo en el IDE, menos tiempo revisando la documentación de la API.
  • Mantenibilidad: El código es más fácil de entender y refactorizar.
  • Consistencia: El cliente está siempre sincronizado con la última especificación de la API.
  • Reducción de Boilerplate: El generador se encarga de gran parte del código repetitivo para las llamadas API.

Desventajas

  • Dependencia de OpenAPI: Requiere que la API tenga y mantenga una especificación OpenAPI precisa. Si la especificación está desactualizada o es incorrecta, los tipos generados también lo serán.
  • Curva de Aprendizaje: Puede haber una curva inicial para configurar las herramientas y entender cómo funcionan los tipos generados.
  • Generación de Archivos: Introducción de archivos generados en tu base de código, que deben ser gestionados (commitear vs. ignorar).
  • Personalización: Aunque ofrecen opciones, las herramientas generadas pueden ser menos flexibles para casos de uso muy específicos o patrones de caché complejos, en comparación con un cliente API escrito a mano (aunque esto es raro y suele tener soluciones).

Conclusión 🎉

Tipar APIs REST con TypeScript utilizando especificaciones OpenAPI y herramientas como openapi-typescript y openapi-fetch es una práctica altamente recomendada que eleva la calidad, la seguridad y la eficiencia de tu desarrollo. Al automatizar la generación de tipos y clientes, liberas a tu equipo de la carga de mantener contratos de API manuales y propensos a errores, permitiéndoles centrarse en la lógica de negocio real.

Esperamos que este tutorial te haya proporcionado una comprensión clara y práctica de cómo implementar este poderoso flujo de trabajo en tus propios proyectos. ¡Ahora, ve y construye aplicaciones más robustas y felices con TypeScript y APIs REST tipadas!

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!