Cliente HTTP en TypeScript: haciendo solicitudes correctamente de la A a la Z
\n\nEl desarrollo web moderno es impensable sin la interacción con servidores remotos. Ya sea obtener datos de una API REST, enviar formularios o cargar archivos, las solicitudes HTTP son la base de la arquitectura cliente-servidor. TypeScript, con su estricta tipificación y potentes herramientas, permite trabajar con solicitudes HTTP de manera no solo segura, sino también fácil de mantener. En este artículo, analizaremos cómo crear un cliente HTTP adecuado en TypeScript, evitar errores comunes y escribir código que funcione de manera predecible.
\n\n1. Fundamentos: fetch API y su tipificación
\n\nEl fetch integrado en el navegador es una herramienta moderna y flexible para realizar solicitudes HTTP. Sin embargo, su uso en TypeScript requiere una tipificación correcta de la respuesta. Por defecto, fetch devuelve Promise<Response>, y debemos indicar explícitamente qué datos esperamos recibir.
interface User {\n id: number;\n name: string;\n email: string;\n}\n\nasync function getUser(id: number): Promise<User> {\n const response = await fetch(`https://api.example.com/users/${id}`);\n \n if (!response.ok) {\n throw new Error(`HTTP error! status: ${response.status}`);\n }\n \n const data: User = await response.json();\n return data;\n}\n\n\nObserve la verificación de response.ok. Esto es críticamente importante: incluso si el servidor devuelve un error (404, 500, etc.), fetch no lanzará una excepción automáticamente. Debemos manejar el estado manualmente.
Para solicitudes POST con cuerpo, es necesario especificar el encabezado Content-Type:
async function createUser(userData: Omit<User, 'id'>): Promise<User> {\n const response = await fetch('https://api.example.com/users', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(userData),\n });\n\n if (!response.ok) {\n throw new Error(`Failed to create user: ${response.statusText}`);\n }\n\n return response.json();\n}\n\n\n2. Creación de un cliente HTTP universal
\n\nEn lugar de escribir fetch desde cero cada vez, es mejor crear un envoltorio: un cliente HTTP universal. Esto permitirá gestionar de forma centralizada la URL base, los encabezados y el manejo de errores. Veamos un ejemplo basado en clases:
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';\n\ninterface RequestConfig<TBody = unknown> {\n method: HttpMethod;\n url: string;\n body?: TBody;\n headers?: Record<string, string>;\n params?: Record<string, string>;\n}\n\nclass HttpClient {\n private baseUrl: string;\n private defaultHeaders: Record<string, string>;\n\n constructor(baseUrl: string, defaultHeaders: Record<string, string> = {}) {\n this.baseUrl = baseUrl;\n this.defaultHeaders = {\n 'Content-Type': 'application/json',\n ...defaultHeaders,\n };\n }\n\n private async request<TResponse, TBody = unknown>(\n config: RequestConfig<TBody>\n ): Promise<TResponse> {\n const url = new URL(`${this.baseUrl}${config.url}`);\n \n if (config.params) {\n Object.entries(config.params).forEach(([key, value]) => {\n url.searchParams.append(key, value);\n });\n }\n\n const response = await fetch(url.toString(), {\n method: config.method,\n headers: { ...this.defaultHeaders, ...config.headers },\n body: config.body ? JSON.stringify(config.body) : undefined,\n });\n\n if (!response.ok) {\n const errorBody = await response.text();\n throw new Error(`Request failed: ${response.status} ${response.statusText}. Body: ${errorBody}`);\n }\n\n // Para DELETE u otros métodos que pueden no devolver cuerpo\n if (response.status === 204) {\n return undefined as TResponse;\n }\n\n return response.jso