Skip to content

Building a Type-Safe API Client Generator from OpenAPI Specs

Build a code generator that reads OpenAPI specs and emits fully typed TypeScript API clients with request validation, error handling and type inference.

5 min read
Pipeline diagram showing an OpenAPI spec flowing through a code generator to produce typed TypeScript API client functions

Manually writing API client code is tedious and error-prone. The endpoint path changes and you miss one call site. A response field gets renamed and your types don't match. An OpenAPI spec already describes everything about your API—endpoints, request bodies, response shapes, and error codes. A code generator reads that spec and produces a typed client that stays in sync with your API automatically.

Parsing the OpenAPI Specification

An OpenAPI spec is a structured description of every endpoint. We need to extract the information relevant to client generation: paths, methods, parameters, request bodies, and response types.

tstypescript
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;
}

Resolving Schema References

OpenAPI specs use $ref to avoid duplication. Before generating types, we need to resolve all references to their actual schema definitions.

tstypescript
// ❌ Ignoring $ref — generates incomplete types
function badSchemaToType(schema: SchemaObject): string {
  if (schema.$ref) {
    return "any"; // Gives up on referenced types
  }
  return schema.type ?? "unknown";
}
tstypescript
// ✅ 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/", "");
  }
}

Generating Type Definitions

With the resolver in place, we generate TypeScript interfaces for every schema in the spec.

tstypescript
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";
// }

Generating Client Functions

Each endpoint becomes a typed function. Path parameters are extracted from the URL template, query parameters become optional arguments, and the return type matches the response schema.

tstypescript
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");
}

The Generated Client

The full generator combines type definitions and client functions into a complete module.

tstypescript
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);

Key Takeaways

Code generation from OpenAPI specs eliminates the manual overhead of keeping API clients in sync with backend changes. The generator reads endpoint definitions, resolves schema references, produces TypeScript interfaces for every model, and creates typed functions for every endpoint. Path parameters become function arguments, query parameters become optional objects, and response types are inferred from the spec's response schemas. Run the generator as part of your build pipeline so the client stays current with every API change. The investment in building the generator pays off immediately—no more mismatched types, no more missed endpoint changes, and every developer gets autocomplete and type checking for every API call.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX