OpenAPI·Fetch·Free

OpenAPI to
Fetch.

Turn an OpenAPI 3.0 or 3.1 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.1·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';

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 })
      }
    });
}
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';

export type MethodUnion = CardPayment | BankPayment;

// src/api/models/card-payment.ts
export interface CardPayment {
  kind: 'card';
  last4: string;
}

// src/api/models/bank-payment.ts
export interface BankPayment {
  kind: 'bank';
  iban: string;
}

// Narrowing (yours to write — the types do the work):
function label(m: MethodUnion): string {
  if (m.kind === 'card') return `card ${m.last4}`;
  return `bank ${m.iban}`;
}
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, expand?: string): Promise<ApiResult<Payment>> {
    return apiClient.safeRequest<Payment>({
      method: 'GET',
      url: `/payments/${id}`,
      query: {
        ...(expand !== undefined && { 'expand': expand })
      }
    });
}

// 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
Prefer it in your build?

Same generator, as a package.

The converter above is the fastest way to try the output. For CI and repeatable builds, the identical generator ships as a package — pin a version, wire it into your pipeline, and review diffs like any other code.

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.
More converters

Same engine, other stacks.

Every converter runs the same spec-to-IR pipeline. Pick another source format or target stack.

OpenAPITypeScript
Angular httpResource, React TanStack Query, or framework-agnostic fetch.
Open converter
OpenAPIAngular
Typed services with httpResource, Signals, inject() DI, and interceptors.
Open converter
OpenAPIReact
TanStack Query hooks with stable query keys, or plain async functions.
Open converter
OpenAPIJava Spring
RestClient services, Java records, sealed interfaces, Spring Boot 3.
Open converter
OpenAPIPython
Async httpx clients paired with Pydantic v2 models.
Open converter
OpenAPIGo
Idiomatic net/http client, context-aware, pointer-nullable fields.
Open converter
OpenAPIKotlin
Ktor client, kotlinx.serialization, sealed interfaces for oneOf.
Open converter
OpenAPIC#
Records, nullable reference types, JsonPolymorphic, HttpClient.
Open converter
OpenAPIRust
reqwest client with serde-tagged enums for oneOf and Option<T>.
Open converter
GraphQLAngular
Observable services with typed queries, mutations, and graphql-ws subscriptions.
Open converter
GraphQLReact
TanStack Query hooks with discriminated-union types and query keys.
Open converter
GraphQLKotlin
Ktor, kotlinx.serialization, Flow-based subscriptions.
Open converter
GraphQLC#
Records, nullable types, [JsonPolymorphic] unions, IAsyncEnumerable subscriptions.
Open converter
ProtobufKotlin
Ktor, coroutines, and sealed classes straight from .proto.
Open converter
ProtobufC#
Records, nullable types, Connect RPC over HttpClient.
Open converter
ProtobufGo
Plain structs with json tags, pointer presence, stdlib-only Connect client.
Open converter
SQLTypeScript
Postgres DDL to typed interfaces with foreign-key navigation.
Open converter
SQLKotlin
CREATE TABLE scripts to Kotlin @Serializable data classes.
Open converter

Try it on your own spec.

The converter runs in the browser. Your spec never leaves the page.

Open the converter