DEV Community

Cover image for Implementando un API Client Generator con Proxies de JavaScript y TypeScript Overloads
x0s3
x0s3

Posted on

Implementando un API Client Generator con Proxies de JavaScript y TypeScript Overloads

Generador de clientes: Centralizando el consumo de APIs con Proxies y TypeScript

Si estás trabajando en un ecosistema de microservicios, probablemente estés harto de configurar instancias de Axios/Ky en cada rincón de tu proyecto. El boilerplate se acumula, el tipado se vuelve inconsistente y mantener las configuraciones base (como headers de tracking o transformación de cases) se vuelve una pesadilla.

Hoy quiero mostrarte cómo pasar de un desorden de configuraciones a una interfaz limpia y centralizada.

Esto es lo que queremos evitar ❌

import { axios } from 'axios';

const url = process.env.SERVICE1;
const response = await axios.get(`${url}/user-data`);
Enter fullscreen mode Exit fullscreen mode

La API Final: Así es como debería verse tu código

Imagina que en lugar de importar configuraciones pesadas, simplemente defines tus servicios y ya tienes clientes listos para usar:

import { clientGenerator } from "./api-utils";
import { connector } from "./base-connector";

// 1. Definimos y generamos
const { service1, service2 } = clientGenerator(connector, {
  service1: process.env.SERVICE1,
  service2: process.env.SERVICE2,
});

// 2. Usamos con tipado automático
const data = await service1.get<MyResponse>("/user"); 
// 'data' es MyResponse directamente.
Enter fullscreen mode Exit fullscreen mode

¿Necesitas la respuesta completa? Solo pasas un flag y el tipado cambia mágicamente:

const data = await service1.get<MyResponse>("/user", {
  resolveWithFullResponse: true,
}); 

console.log(response.status); // 200
console.log(response.data.ok); // Tipado
console.log(response.data.MyResponse); // Tipado
Enter fullscreen mode Exit fullscreen mode

¿Cómo llegamos a esto? La magia por dentro

Para lograr esta sintaxis tan limpia sin repetir código para cada método HTTP (get, post, put...), usamos dos herramientas poderosas: Proxies de JavaScript y Sobrecarga de funciones en TypeScript.

1. El Proxy como "Atrapa-todo"

En lugar de definir un objeto con métodos manuales, usamos un Proxy. Este intercepta cualquier propiedad que intentes acceder (como .get o .post) y la convierte dinámicamente en una llamada al conector.

const client = (connector: Connector, baseURL: string) =>
  new Proxy<Client>({} as Client, {
    get(_target, method: HTTPMethod) {
      const requestMethod: ClientMethod = <TResponse = unknown>(
        endpoint: `/${string}`,
        options: ClientRequestConfig = {},
      ) => {
        const requestBase = {
          ...options,
          baseURL,
          url: endpoint,
          method: method.toUpperCase(),
        };

        // Casting to `any` so that the type inference works correctly :D
        return connector<TResponse>(requestBase as any);
      };

      return requestMethod;
    },
  });
Enter fullscreen mode Exit fullscreen mode

Esto hace que nuestro cliente sea virtualmente infinito y extremadamente ligero. No importa si mañana el conector soporta un nuevo verbo HTTP; el Proxy simplemente lo capturará.

2. Tipado inteligente (Function Overloads)

Uno de los retos era que, a veces, queremos solo la data y otras veces el AxiosResponse completo. Para que TypeScript no nos dé errores ni use any, aplicamos sobrecarga de tipos:

type ClientMethod = {
  // Sobrecarga 1: Si no pides la respuesta completa, devuelve TResponse
  <TResponse = unknown>(
    endpoint: `/${string}`,
    options?: ClientRequestConfigNormal,
  ): Promise<TResponse>;

  // Sobrecarga 2: Si resolveWithFullResponse es true, devuelve AxiosResponse<TResponse>
  <TResponse = unknown>(
    endpoint: `/${string}`,
    options: ClientRequestConfigFullResponse,
  ): Promise<AxiosResponse<TResponse>>;
};
Enter fullscreen mode Exit fullscreen mode

Esto es lo que permite que el IDE sea "inteligente" y sepa exactamente qué tipo de objeto tienes entre manos según las opciones que envíes.

3. La Factoría: clientGenerator

Finalmente, unificamos todo en una función que toma un mapa de URLs y nos devuelve el objeto final usando Object.fromEntries:

export const clientGenerator = <T extends ServiceMap, R = { [K in keyof T]: Client }>(
  connector: Connector,
  services: T,
): R =>
  Object.fromEntries(
    Object.entries(services).map(([key, baseURL]) => [key, client(connector, baseURL)]),
  ) as R;
Enter fullscreen mode Exit fullscreen mode

 El "Perfect Match": RSC y React Query

Donde esto realmente brilla es en el frontend moderno.

React Server Components (RSC)

Esta estrategia casa perfectamente con la filosofía de tener One API to rule them all. En entornos con Server Components, quieres que tus llamadas a la API sean lo más planas y directas posibles.

Al centralizar todo en un generador, tus componentes de servidor se mantienen limpios de lógica de infraestructura, delegando el manejo de tokens, certificados o headers de tracking al connector base.

 Integración con React Query

La integración aquí es increíble. Como el generador devuelve una promesa tipada, react-query infiere el tipo de data automáticamente:

// El tipado de 'data' se hereda de la respuesta del cliente automáticamente
const { data } = useQuery({
  queryKey: ['fines', id],
  queryFn: () => fines.get<FinesDetail>(`/fines/${id}`)
});
Enter fullscreen mode Exit fullscreen mode

Esto elimina la necesidad de declarar genéricos manualmente en cada useQuery y asegura que lo que dice tu API sea exactamente lo que llega a tus componentes.


Conclusión

Al abstraer la creación de clientes con un generador centralizado, no solo limpias tu código de infraestructura, sino que garantizas que todos los microservicios de tu ecosistema se consuman bajo las mismas reglas (mismos headers, mismo manejo de errores, mismo tracking).

Es una inversión pequeña en arquitectura que paga dividendos enormes en mantenibilidad y Developer Experience.

¿Qué les parece este enfoque? ¿Usan Proxies en sus utils o prefieren definiciones más explícitas?

Top comments (0)