Einen typsicheren API-Client-Generator aus OpenAPI bauen
Baue einen Codegenerator, der OpenAPI-Spezifikationen liest und typisierte TypeScript-Clients mit Validierung, Fehlerbehandlung und Typinferenz erzeugt.

API-Client-Code von Hand zu schreiben ist mühsam und fehleranfällig. Der Pfad eines Endpunkts ändert sich, und eine Aufrufstelle wird übersehen. Ein Feld in der Antwort wird umbenannt, und die Typen stimmen nicht mehr überein. Eine OpenAPI-Spezifikation beschreibt bereits alles über deine API — Endpunkte, Request-Bodies, Antwortformen und Fehlercodes. Ein Codegenerator liest diese Spezifikation und erzeugt einen typisierten Client, der automatisch mit deiner API synchron bleibt.
Die OpenAPI-Spezifikation parsen
Eine OpenAPI-Spezifikation ist eine strukturierte Beschreibung jedes Endpunkts. Wir müssen die für die Client-Generierung relevanten Informationen extrahieren: Pfade, Methoden, Parameter, Request-Bodies und Antworttypen.
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;
}Schema-Referenzen auflösen
OpenAPI-Spezifikationen verwenden $ref, um Duplizierung zu vermeiden. Bevor wir Typen generieren, müssen wir alle Referenzen zu ihren tatsächlichen Schemadefinitionen auflösen.
// ❌ 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/", "");
}
}Typdefinitionen generieren
Da der Resolver nun vorhanden ist, generieren wir für jedes Schema in der Spezifikation ein TypeScript-Interface.
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";
// }Client-Funktionen generieren
Jeder Endpunkt wird zu einer typisierten Funktion. Pfadparameter werden aus der URL-Vorlage extrahiert, Query-Parameter werden zu optionalen Argumenten, und der Rückgabetyp entspricht dem Antwortschema.
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");
}Der generierte Client
Der vollständige Generator kombiniert Typdefinitionen und Client-Funktionen zu einem vollständigen Modul.
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);Wichtigste Erkenntnisse
Codegenerierung aus OpenAPI-Spezifikationen erspart den manuellen Aufwand, API-Clients bei Änderungen am Backend synchron zu halten. Der Generator liest die Endpunktdefinitionen, löst Schema-Referenzen auf, erzeugt TypeScript-Interfaces für jedes Modell und erstellt typisierte Funktionen für jeden Endpunkt. Pfadparameter werden zu Funktionsargumenten, Query-Parameter werden zu optionalen Objekten, und Antworttypen werden aus den Antwortschemata der Spezifikation abgeleitet. Führe den Generator als Teil deiner Build-Pipeline aus, damit der Client bei jeder API-Änderung aktuell bleibt. Die Investition in den Bau des Generators zahlt sich sofort aus: keine falsch zugeordneten Typen mehr, keine übersehenen Endpunktänderungen mehr, und jeder Entwickler bekommt Autovervollständigung und Typprüfung für jeden API-Aufruf.


