OpenAPI to
Fetch.
Turn an OpenAPI 3.0 spec into plain async functions and a tiny typed client. ApiResult<T> for errors as values. Middleware hooks, retries with backoff, and async bearer tokens — opt-in flags, emitted into the client. Runs unmodified on Node, browser, Vite, SvelteKit, and Next.js 15+. No signup, no install.
openapi: 3.0.3
info:
title: Payments API
version: 1.2.0
paths:
/payments/{id}:
get:
operationId: getPayment
tags: [payments]
parameters:
- name: id
in: path
schema: { type: string }
- name: expand
in: query
schema: { type: string }
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/Payment'
components:
schemas:
Payment:
type: object
required: [id, amount, method]
properties:
id: { type: string }
amount: { type: number }
currency:
type: string
nullable: true
method:
oneOf:
- $ref: '#/components/schemas/CardPayment'
- $ref: '#/components/schemas/BankPayment'
discriminator:
propertyName: kind// src/api/services/payments.service.ts
import { ApiClient } from '../client';
import { Payment } from '../models/payment';
import { CreatePaymentRequest } from '../models/create-payment-request';
export async function getPayment(apiClient: ApiClient, id: string, expand?: string): Promise<Payment> {
return apiClient.request<Payment>({
method: 'GET',
url: `/payments/${id}`,
query: {
...(expand !== undefined && { 'expand': expand })
}
});
}
export async function createPayment(apiClient: ApiClient, body: CreatePaymentRequest): Promise<Payment> {
return apiClient.request<Payment>({
method: 'POST',
url: '/payments',
body: body
});
}Types, a tiny client, one file per thing.
Below: types emitted from a small payments spec with a oneOf discriminator. Each model lands in its own file. The union is a real TypeScript type, and the discriminator becomes a string literal on each variant so narrowing works without casts.
// src/api/models/payment.ts
import { MethodUnion } from '../union-types';
export interface Payment {
id: string;
amount: number;
method: MethodUnion;
currency?: string | null;
}
// src/api/union-types.ts
import { CardPayment } from './models/card-payment';
import { BankPayment } from './models/bank-payment';
import { WalletPayment } from './models/wallet-payment';
export type MethodUnion = CardPayment | BankPayment | WalletPayment;
// src/api/models/card-payment.ts
export interface CardPayment {
kind: 'card';
last4: string;
}
// Narrowing (yours to write — the types do the work):
function label(m: MethodUnion): string {
if (m.kind === 'card') return `card ${m.last4}`;
if (m.kind === 'bank') return `bank ${m.iban}`;
return `wallet ${m.provider}`;
}Grounded in what gets emitted.
Each claim here is something you can verify by running the converter on your own spec. Click a card to see the flag that turned it on and the code that came out.
# Opt in via one flag:
npx @metaengine/openapi-fetch payments.yaml ./src/api \
--result-pattern// src/api/errors.ts
export class HttpError extends Error {
constructor(
public readonly status: number,
public readonly statusText: string,
public readonly response?: Response,
public readonly body?: unknown
) {
super(`HTTP ${status}: ${statusText}`);
this.name = 'HttpError';
}
}
export type ApiResult<T, E = HttpError> =
| { ok: true; data: T }
| { ok: false; error: E };
// src/api/services/payments.service.ts
export async function getPayment(apiClient: ApiClient, id: string): Promise<ApiResult<Payment>> {
return apiClient.safeRequest<Payment>({
method: 'GET',
url: `/payments/${id}`
});
}
// In your code — errors are branches, not try/catch:
const res = await getPayment(apiClient, 'pay_123');
if (!res.ok) return renderError(res.error); // status + body attached
showPayment(res.data);Lazy singleton from an env var.
Pass --base-url-env API_BASE_URL and the generator emits a getDefaultClient() that reads process.env.API_BASE_URL the first time it is called. If the var is unset, it throws a human-readable error pointing at your .env file.
// src/api/client.ts — generated with --base-url-env API_BASE_URL
declare const process: {
env: {
API_BASE_URL?: string;
};
} | undefined;
let _defaultClient: ApiClient | undefined;
/**
* Returns a lazily-initialized default API client.
* Reads the base URL from the API_BASE_URL environment variable.
*/
export function getDefaultClient(): ApiClient {
if (!_defaultClient) {
const baseUrl = process?.env.API_BASE_URL;
if (!baseUrl) {
throw new Error(
'Environment variable "API_BASE_URL" is not defined. ' +
'Please set it in your .env file (e.g., API_BASE_URL=http://localhost:3000/api)'
);
}
_defaultClient = createClient({ baseUrl });
}
return _defaultClient;
}Or ask Claude to do it.
The same generator ships as a Model Context Protocol server. It calls the same web API this converter uses, so the output is identical. Point Claude Desktop, Cursor, or any MCP-aware agent at a spec and it produces the TypeScript Fetch client as a diff — free, no token, no leaving the chat.
// claude-desktop config · .mcp.json
{
"mcpServers": {
"metaengine": {
"command": "npx",
"args": ["-y", "@metaengine/mcp-server"]
}
}
}
// Then in Claude:
// "Load specs/payments.yaml and generate a
// TypeScript Fetch client with ApiResult, middleware,
// retries, and an import.meta.env default client."Things people ask.
The same set of questions we keep getting about the Fetch target. If yours is not here, run the converter — the answer is usually in the output.
.ts files: one interface per schema, one enum per enum, a union-types.ts for every oneOf discriminator, a tiny client.ts with createClient() and an ApiClient interface, and services/<tag>.service.ts with plain async functions for each operation. No runtime dependencies, no generated class hierarchy.Try it on your own spec.
The converter runs in the browser. Your spec never leaves the page.