Un generador de clientes de API tipados desde OpenAPI
Crea un generador que lee especificaciones OpenAPI y produce clientes TypeScript tipados, con validación, manejo de errores e inferencia de tipos.

Escribir manualmente el código de un cliente de API es tedioso y propenso a errores. Cambia la ruta de un endpoint y se te escapa un punto de llamada. Se renombra un campo de la respuesta y tus tipos dejan de coincidir. Una especificación OpenAPI ya describe todo sobre tu API: endpoints, cuerpos de solicitud, formas de respuesta y códigos de error. Un generador de código lee esa especificación y produce un cliente tipado que se mantiene sincronizado con tu API de forma automática.
Análisis de la especificación OpenAPI
Una especificación OpenAPI es una descripción estructurada de cada endpoint. Necesitamos extraer la información relevante para la generación del cliente: rutas, métodos, parámetros, cuerpos de solicitud y tipos de respuesta.
interface OpenAPISpec {
paths: Record<string, PathItem>;
components?: {
schemas?: Record<string, SchemaObject>;
};
}
interface PathItem {
get?: Operation;
post?: Operation;
put?: Operation;
patch?: Operation;
delete?: Operation;
}
interface Operation {
operationId?: string;
summary?: string;
parameters?: Parameter[];
requestBody?: RequestBody;
responses: Record<string, ResponseObject>;
tags?: string[];
}
interface Parameter {
name: string;
in: "path" | "query" | "header";
required: boolean;
schema: SchemaObject;
}
interface RequestBody {
required?: boolean;
content: Record<string, { schema: SchemaObject }>;
}
interface ResponseObject {
description: string;
content?: Record<string, { schema: SchemaObject }>;
}
interface SchemaObject {
type?: string;
properties?: Record<string, SchemaObject>;
required?: string[];
items?: SchemaObject;
$ref?: string;
enum?: string[];
format?: string;
}Resolución de referencias de esquema
Las especificaciones OpenAPI usan $ref para evitar la duplicación. Antes de generar los tipos, necesitamos resolver todas las referencias a sus definiciones de esquema reales.
// ❌ Ignoring $ref — generates incomplete types
function badSchemaToType(schema: SchemaObject): string {
if (schema.$ref) {
return "any"; // Gives up on referenced types
}
return schema.type ?? "unknown";
}// ✅ Proper $ref resolution
class SchemaResolver {
private schemas: Record<string, SchemaObject>;
private resolved: Map<string, string> = new Map();
constructor(spec: OpenAPISpec) {
this.schemas = spec.components?.schemas ?? {};
}
resolve(schema: SchemaObject): SchemaObject {
if (schema.$ref) {
const refPath = schema.$ref.replace("#/components/schemas/", "");
const referenced = this.schemas[refPath];
if (!referenced) {
throw new Error(`Unresolved $ref: ${schema.$ref}`);
}
return this.resolve(referenced);
}
return schema;
}
schemaToTypeScript(
schema: SchemaObject,
indent: string = ""
): string {
const resolved = this.resolve(schema);
if (resolved.$ref) {
return this.refToTypeName(resolved.$ref);
}
if (resolved.enum) {
return resolved.enum.map(v => `"${v}"`).join(" | ");
}
switch (resolved.type) {
case "string":
return resolved.format === "date-time" ? "string" : "string";
case "number":
case "integer":
return "number";
case "boolean":
return "boolean";
case "array":
if (resolved.items) {
const itemType = this.schemaToTypeScript(resolved.items);
return `${itemType}[]`;
}
return "unknown[]";
case "object":
return this.objectToTypeScript(resolved, indent);
default:
return "unknown";
}
}
private objectToTypeScript(
schema: SchemaObject,
indent: string
): string {
if (!schema.properties) return "Record<string, unknown>";
const required = new Set(schema.required ?? []);
const props = Object.entries(schema.properties).map(
([name, propSchema]) => {
const optional = required.has(name) ? "" : "?";
const type = this.schemaToTypeScript(propSchema, indent + " ");
return `${indent} ${name}${optional}: ${type};`;
}
);
return `{\n${props.join("\n")}\n${indent}}`;
}
private refToTypeName(ref: string): string {
return ref.replace("#/components/schemas/", "");
}
}Generación de definiciones de tipos
Una vez implementado el resolver, generamos interfaces de TypeScript para cada esquema de la especificación.
function generateTypeDefinitions(spec: OpenAPISpec): string {
const resolver = new SchemaResolver(spec);
const schemas = spec.components?.schemas ?? {};
const lines: string[] = [];
lines.push("// Auto-generated from OpenAPI spec");
lines.push("// Do not edit manually\n");
for (const [name, schema] of Object.entries(schemas)) {
const typeBody = resolver.schemaToTypeScript(schema);
lines.push(`export interface ${name} ${typeBody}\n`);
}
return lines.join("\n");
}
// Example output:
// export interface User {
// id: number;
// name: string;
// email: string;
// role: "admin" | "editor" | "viewer";
// createdAt: string;
// }
//
// export interface CreateUserRequest {
// name: string;
// email: string;
// role?: "admin" | "editor" | "viewer";
// }Generación de funciones del cliente
Cada endpoint se convierte en una función tipada. Los parámetros de ruta se extraen de la plantilla de la URL, los parámetros de consulta pasan a ser argumentos opcionales, y el tipo de retorno coincide con el esquema de la respuesta.
interface EndpointInfo {
method: string;
path: string;
operationId: string;
pathParams: Parameter[];
queryParams: Parameter[];
requestBody?: SchemaObject;
responseType: string;
summary?: string;
}
function extractEndpoints(spec: OpenAPISpec): EndpointInfo[] {
const resolver = new SchemaResolver(spec);
const endpoints: EndpointInfo[] = [];
for (const [path, pathItem] of Object.entries(spec.paths)) {
const methods = ["get", "post", "put", "patch", "delete"] as const;
for (const method of methods) {
const operation = pathItem[method];
if (!operation) continue;
const operationId =
operation.operationId ??
`${method}${path.replace(/[^a-zA-Z]/g, "_")}`;
const params = operation.parameters ?? [];
const pathParams = params.filter(p => p.in === "path");
const queryParams = params.filter(p => p.in === "query");
let requestBody: SchemaObject | undefined;
if (operation.requestBody?.content?.["application/json"]) {
requestBody = resolver.resolve(
operation.requestBody.content["application/json"].schema
);
}
const successResponse =
operation.responses["200"] ?? operation.responses["201"];
let responseType = "void";
if (successResponse?.content?.["application/json"]) {
responseType = resolver.schemaToTypeScript(
successResponse.content["application/json"].schema
);
}
endpoints.push({
method,
path,
operationId,
pathParams,
queryParams,
requestBody,
responseType,
summary: operation.summary,
});
}
}
return endpoints;
}
function generateClientFunction(
endpoint: EndpointInfo,
resolver: SchemaResolver
): string {
const lines: string[] = [];
if (endpoint.summary) {
lines.push(`/** ${endpoint.summary} */`);
}
// Build parameter list
const params: string[] = [];
for (const p of endpoint.pathParams) {
const type = resolver.schemaToTypeScript(p.schema);
params.push(`${p.name}: ${type}`);
}
if (endpoint.requestBody) {
const type = resolver.schemaToTypeScript(endpoint.requestBody);
params.push(`body: ${type}`);
}
if (endpoint.queryParams.length > 0) {
const queryProps = endpoint.queryParams
.map(p => {
const optional = p.required ? "" : "?";
const type = resolver.schemaToTypeScript(p.schema);
return `${p.name}${optional}: ${type}`;
})
.join("; ");
params.push(`query?: { ${queryProps} }`);
}
const paramStr = params.join(", ");
const returnType = endpoint.responseType;
lines.push(
`export async function ${endpoint.operationId}(${paramStr}): Promise<${returnType}> {`
);
// Build URL with path params
let urlExpr = `\`${endpoint.path.replace(
/\{(\w+)\}/g,
"${$1}"
)}\``;
// Add query string
if (endpoint.queryParams.length > 0) {
lines.push(" const searchParams = new URLSearchParams();");
lines.push(" if (query) {");
lines.push(
" for (const [key, value] of Object.entries(query)) {"
);
lines.push(
" if (value !== undefined) searchParams.set(key, String(value));"
);
lines.push(" }");
lines.push(" }");
lines.push(
` const queryString = searchParams.toString();`
);
lines.push(
` const url = queryString ? ${urlExpr} + "?" + queryString : ${urlExpr};`
);
} else {
lines.push(` const url = ${urlExpr};`);
}
// Build fetch options
lines.push(` const response = await fetch(baseUrl + url, {`);
lines.push(` method: "${endpoint.method.toUpperCase()}",`);
if (endpoint.requestBody) {
lines.push(` headers: { "Content-Type": "application/json" },`);
lines.push(` body: JSON.stringify(body),`);
}
lines.push(" });");
lines.push("");
lines.push(" if (!response.ok) {");
lines.push(
" throw new ApiError(response.status, await response.text());"
);
lines.push(" }");
lines.push("");
if (returnType === "void") {
lines.push(" return;");
} else {
lines.push(` return response.json() as Promise<${returnType}>;`);
}
lines.push("}");
return lines.join("\n");
}El cliente generado
El generador completo combina las definiciones de tipos y las funciones del cliente en un único módulo.
function generateClient(spec: OpenAPISpec): string {
const sections: string[] = [];
sections.push("// Auto-generated API client");
sections.push("// Do not edit manually\n");
sections.push("const baseUrl = process.env.API_BASE_URL ?? '';\n");
sections.push(
"class ApiError extends Error {\n" +
" constructor(public status: number, public body: string) {\n" +
' super(`API error ${status}: ${body}`);\n' +
" this.name = 'ApiError';\n" +
" }\n" +
"}\n"
);
// Type definitions
sections.push(generateTypeDefinitions(spec));
sections.push("");
// Client functions
const resolver = new SchemaResolver(spec);
const endpoints = extractEndpoints(spec);
for (const endpoint of endpoints) {
sections.push(generateClientFunction(endpoint, resolver));
sections.push("");
}
return sections.join("\n");
}
// Usage in build pipeline:
// const spec = JSON.parse(readFileSync("openapi.json", "utf-8"));
// const client = generateClient(spec);
// writeFileSync("src/api/client.generated.ts", client);Puntos clave
La generación de código a partir de especificaciones OpenAPI elimina el esfuerzo manual de mantener los clientes de API sincronizados con los cambios del backend. El generador lee las definiciones de los endpoints, resuelve las referencias de esquema, produce interfaces de TypeScript para cada modelo y crea funciones tipadas para cada endpoint. Los parámetros de ruta se convierten en argumentos de función, los parámetros de consulta se convierten en objetos opcionales, y los tipos de respuesta se infieren de los esquemas de respuesta de la especificación. Ejecuta el generador como parte de tu pipeline de compilación para que el cliente se mantenga al día con cada cambio de la API. La inversión en construir el generador se recupera de inmediato: se acabaron los tipos que no coinciden, se acabaron los cambios de endpoints que pasan desapercibidos, y cada desarrollador obtiene autocompletado y verificación de tipos en cada llamada a la API.


