OpenAPI·Fetch·Free

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·Zero deps·Result<T>·Middleware·MIT
payments.openapi.yamlInput
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
compile
src/api/services/payments.service.ts tsc cleanOutput
// 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
    });
}
What gets generated

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}`;
}
Three things we get right

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.

01ProofErrors are values, and they carry the status.Spec · InCode · Out
npx invocation
# Opt in via one flag:
npx @metaengine/openapi-fetch payments.yaml ./src/api \
  --result-pattern
emitted .ts files
// 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);
Node · process.env

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
// 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;
}
Also available via MCP

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."
See the MCP page
Questions

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.

A tree of .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.

Open the converter