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`);
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.
¿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
¿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;
},
});
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>>;
};
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;
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}`)
});
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)