Which OpenAPI Codegen Should You Choose? openapi-typescript vs hey-api vs Orval vs Kubb
Architecting Type-Safe API Clients: A Performance and Maintainability Analysis of OpenAPI Generators
Current Situation Analysis
Manual API client maintenance is a well-documented engineering tax. Teams routinely invest hours synchronizing request payloads, response shapes, and authentication headers across frontend and backend codebases. OpenAPI code generation promises to eliminate this friction by deriving clients directly from specification files. However, the industry has largely treated codegen as a binary switch: either you generate types, or you don't. This oversimplification ignores the architectural consequences of the generated output.
The real challenge isn't whether a tool can produce TypeScript interfaces or SDK functions. It's how that output behaves under production conditions. When a specification scales beyond a few dozen endpoints, hidden costs emerge: generation latency blocks local feedback loops, excessive file fragmentation degrades IDE indexing and repository checkout times, linting pipelines choke on auto-generated artifacts, and error modeling diverges from application-level conventions. Authentication flows, interceptor patterns, and result shaping also vary drastically between generators, forcing teams to write adapter layers that negate the original productivity gains.
Benchmarks against a real-world enterprise schema (~75,000 lines, ~2MB, ~1,200 operations) reveal stark divergence. Generation times range from 1.5 seconds to over 18 seconds. Output file counts span from a single monolithic file to nearly 4,000 granular modules. Total disk footprint varies from 2.4MB to 24MB. These metrics directly impact developer experience, CI/CD throughput, and long-term maintainability. Selecting a generator without evaluating these operational characteristics inevitably leads to technical debt, inconsistent error handling, and fragmented team workflows.
WOW Moment: Key Findings
The data exposes a fundamental trade-off between generation speed, output granularity, and ecosystem breadth. Tools cluster into two distinct architectural families: lightweight type-focused generators that prioritize speed and minimal I/O, and SDK-first platforms that generate comprehensive client layers, validation schemas, and testing mocks at the cost of build time and file fragmentation.
| Generator | Avg. Build Time | Output Files | Total Disk Size | Architectural Focus |
|---|---|---|---|---|
openapi-typescript |
~1.5 s | 1 | 2.4 MB | Type-only, minimal runtime |
@hey-api/openapi-ts |
~8.0 s | 16 | 2.9 MB | OperationId SDK, interceptors, result-style errors |
Orval |
~5.5 s | 2,719 | 14 MB | Tag-split SDK, TanStack Query/Zod/MSW ecosystem |
Kubb |
~18.1 s | 3,877 | 24 MB | 1-operation-per-file, plugin-driven codegen platform |
This comparison matters because it shifts the evaluation criteria from feature checklists to operational reality. A generator that produces 3,800 files may offer perfect tree-shaking and granular imports, but it will slow down git clone, increase merge conflicts, and strain TypeScript language servers. Conversely, a single 2.4MB type file compiles instantly but can trigger IDE indexing timeouts and obscure refactoring boundaries. The choice dictates how your team handles errors, manages authentication, integrates with data-fetching libraries, and scales the codebase as the API surface grows.
Core Solution
Building a production-ready API client layer requires aligning generator capabilities with application architecture. The following implementation demonstrates a pragmatic approach using an operationId-based SDK generator, structured for consistent argument shaping, result-style error handling, and interceptor support.
Step 1: Schema Ingestion and Generator Configuration
Start by isolating the OpenAPI specification and configuring the generator to produce operationId-derived functions with a unified request shape. Avoid tag-based splitting unless your bundle size requires it. OperationId mapping enables direct IDE refactoring and predictable import paths.
// openapi.config.ts
import { defineConfig } from '@hey-api/openapi-ts';
export default defineConfig({
input: './specs/warehouse-v2.yaml',
output: {
path: './src/api/generated',
format: 'prettier',
lint: 'eslint',
},
plugins: [
{
name: '@hey-api/client',
baseUrl: '', // Defer to runtime configuration
},
{
name: '@hey-api/sdk',
operationId: true,
resultStyle: 'data-error',
},
],
});
Step 2: Runtime Client Initialization
Generated clients should never hardcode base URLs. Instead, wrap the generated client factory in a configuration layer that supports multi-environment routing, header injection, and timeout policies.
// src/api/client.ts
import { createClient } from './generated/client.gen';
import type { Client } from './generated/client.gen';
export function initializeApi(config: {
baseUrl: string;
timeout?: number;
getToken: () => Promise<string>;
}): Client {
const client = createClient({
baseUrl: config.baseUrl,
timeout: config.timeout ?? 15000,
});
client.interceptors.request.use(async (req) => {
const token = await config.getToken();
if (token) {
req.headers.set('Authorization', `Bearer ${token}`);
}
return req;
});
client.interceptors.response.use((res) => {
if (!res.ok) {
console.warn(`[API] ${res.status} on ${res.url}`);
}
return res;
});
return client;
}
Step 3: Consistent Request Shaping and Result Handling
Generators that enforce a uniform argument structure ({ query, path, body }) simplify middleware, logging, and retry logic. Pair this with a result-style response model to separate network failures from business logic errors.
// src/api/operations/inventory.ts
import { fetchInventoryBatch, submitRestockRequest } from '../generated/sdk.gen';
import type { FetchInventoryBatchData, SubmitRestockRequestData } from '../generated/types.gen';
export async function getWarehouseStock(
locationId: string,
filters: { minQuantity?: number; category?: string }
) {
const result = await fetchInventoryBatch({
path: { location_id: locationId },
query: filters,
});
if (result.error) {
throw new ApiError(result.error.status, result.error.message);
}
return result.data;
}
export async function requestRestock(payload: { sku: string; quantity: number }) {
const result = await submitRestockRequest({
body: payload,
});
if (result.error) {
return { success: false, reason: result.error.message };
}
return { success: true, trackingId: result.data.tracking_id };
}
Architecture Decisions and Rationale
- OperationId-based functions over path strings: Path strings (
client.GET('/inventory')) are readable but fragile during refactoring. OperationId functions (fetchInventoryBatch) enable IDE rename refactoring, static analysis, and explicit dependency tracking. - Result-style errors over try/catch: Wrapping responses in
{ data, error }prevents exception-based control flow, which is expensive in hot paths and obscures error boundaries. It also aligns with functional patterns and simplifies testing. - Deferred base URL configuration: Hardcoding URLs in generated code breaks environment switching and breaks containerized deployments. Runtime initialization preserves portability.
- Interceptor layer separation: Authentication, logging, and retry logic belong in interceptors, not in individual operation calls. This keeps generated code untouched and centralizes cross-cutting concerns.
Pitfall Guide
1. Ignoring File I/O Overhead
Explanation: Generators that split output per operation or per schema create thousands of files. Disk write latency dominates build time, and version control struggles with massive diffs. Fix: Limit granularity to tag-level or module-level splits. Commit generated files only in CI, or use a monolithic output strategy for schemas under 500 operations.
2. Treating Generated Files as Source Code
Explanation: Linters and formatters applied to auto-generated code cause constant noise, merge conflicts, and CI failures.
Fix: Add generated directories to .eslintignore, .prettierignore, and .gitattributes (set linguist-generated). Run linting only on hand-written wrappers.
3. Hardcoding Base URLs in Generated Clients
Explanation: Embedding environment-specific URLs in generated code forces regeneration for every deployment target and breaks container orchestration. Fix: Configure generators to output empty or placeholder base URLs. Initialize clients at runtime with environment variables or configuration providers.
4. Assuming Try/Catch Covers API Errors
Explanation: Network failures, HTTP 4xx/5xx responses, and validation errors require different handling strategies. Exception-based flow mixes concerns and complicates telemetry. Fix: Adopt result-style responses or explicit error unions. Map HTTP status codes to domain-specific error types before they reach UI components.
5. Overlooking IDE Indexing Limits
Explanation: A single 2.4MB TypeScript file or 4,000 small files both strain language servers. One causes memory spikes; the other causes file-watcher overhead.
Fix: Split large type files into logical modules. Use skipLibCheck: true in tsconfig.json. Monitor IDE memory usage and adjust generator output granularity accordingly.
6. Neglecting Schema Drift Detection
Explanation: Generated clients silently diverge from backend contracts when specifications change. Runtime errors appear only in production. Fix: Add a CI step that compares the generated client against a snapshot or runs type-checking against mock servers. Fail builds on contract mismatches.
7. Feature Bloat vs. Bundle Size
Explanation: Generating MSW mocks, Zod schemas, and TanStack Query hooks when only fetch clients are needed increases bundle size and maintenance surface. Fix: Enable plugins selectively. Use tree-shaking-friendly exports. Audit generated code in production builds to remove unused artifacts.
Production Bundle
Action Checklist
- Audit schema size and operation count before selecting a generator
- Configure output granularity to match team refactoring habits
- Implement runtime client initialization with deferred base URLs
- Adopt result-style error handling across all API operations
- Add generated directories to linter and formatter ignore lists
- Set up CI contract validation to detect specification drift
- Monitor IDE indexing performance and adjust file splitting strategy
- Document interceptor patterns for auth, logging, and retries
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Small API (<200 ops), types-only needed | openapi-typescript |
Minimal I/O, instant generation, single file output | Low build cost, negligible disk usage |
| Mid-large API, needs SDK + interceptors + consistent args | @hey-api/openapi-ts |
OperationId functions, result-style errors, lightweight output | Moderate build time, clean DX |
| Full ecosystem (hooks, validation, mocks) required | Orval |
Tag-split SDK, built-in TanStack Query/Zod/MSW support | Higher file count, moderate build time |
| Strict 1-op-1-file structure, plugin-driven architecture | Kubb |
Granular imports, tree-shaking friendly, extensible plugins | High build time, significant disk/CI overhead |
Configuration Template
// openapi-ts.config.ts
import { defineConfig } from '@hey-api/openapi-ts';
export default defineConfig({
input: './openapi.yaml',
output: {
path: './src/api/generated',
format: 'prettier',
lint: 'eslint',
clean: true,
},
plugins: [
{
name: '@hey-api/client',
baseUrl: '',
transport: 'fetch',
},
{
name: '@hey-api/sdk',
operationId: true,
resultStyle: 'data-error',
asClass: false,
},
],
});
Quick Start Guide
- Install the generator and peer dependencies:
npm i -D @hey-api/openapi-ts - Place your OpenAPI specification in the project root or a dedicated
specs/directory. - Create the configuration file above and run
npx openapi-tsto generate the client layer. - Initialize the client at application startup using environment variables, then import operation functions directly into your service or data-access layer.
Mid-Year Sale β Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all tutorials.
Sign In / Register β Start Free Trial7-day free trial Β· Cancel anytime Β· 30-day money-back
