Skip to main content
CodeAlchemy
OpenAPI

OpenAPI → TypeScript in 30 seconds: a practical recipe

CodeAlchemy Team··6 min read

Source: OpenAPI 3.x specification

OpenAPI

You have a backend team. They published an OpenAPI 3.x spec. You have ten minutes before standup. The goal: a fully typed TypeScript client, ready to ship, with zero hand-written interfaces, runtime validation for the responses you don't trust, and one source of truth that won't drift from the API.

The shortest path from a spec to typed network code is shorter than people think — if the generator is good and you don't fight it.

The 30-second recipe

1. Paste the spec. YAML or JSON, OpenAPI 3.0 or 3.1. Whether your spec lives in api.yaml or comes back from /openapi.json, just paste the contents. 2. Pick options. PascalCase naming, fetch (no dependencies), Zod validation. These three defaults match what most modern Next.js / Vite codebases want. 3. Convert. You get four output tabs: types.ts, schemas.ts, client.ts, hooks.ts. Copy whichever you need into your project.

That's literally it. The rest of this post explains what the generator did, why those defaults are sensible, and where you'll want to deviate.

What you actually get

`types.ts` is the cheap, compile-time half. Every #/components/schemas/User becomes a TypeScript interface. oneOf becomes a discriminated union. enum becomes a string-literal union. required is honored — required fields are non-optional, optional fields get ?. The output reads like the file you'd write by hand if you had three uninterrupted hours and a strong sense of what good looks like.

`schemas.ts` is the runtime half. Same shape data, but as Zod schemas. The point isn't to duplicate work — it's to validate untrusted boundaries. When a backend (or worse, a third-party API) hands you JSON, parsing through Zod proves the shape *at runtime*, not just at compile time. You can pipe responses through .parse() in the network layer and TypeScript will infer the right type from there.

`client.ts` is a thin fetch-based HTTP client. Each operation in paths becomes a function: getUser(id), createPost(body). Path parameters are interpolated, query strings are built, request bodies are JSON-encoded, and responses are typed. You can swap fetch for axios if you have an existing interceptor stack — the generator changes the runtime but keeps the type signatures identical.

`hooks.ts` is TanStack Query integration. Each operation becomes a useGetUser (queries) or useCreatePost (mutations) hook with the right cache key, the correct response type, and Zod parsing already wired up if you turned validation on. For React or Vue projects with TanStack Query, this saves a real chunk of repetitive boilerplate.

Defaults: why these specific ones

Naming `PascalCase`. It's the convention in the TypeScript ecosystem and the only style that gives you names you can use directly: LoginRequest, User, PaginationParams. camelCase would force you to alias every type just to satisfy ESLint rules around type names.

Client `fetch`. Adds zero dependencies. The generated request() helper handles content negotiation, JSON encoding, and basic error semantics. If you already use axios for interceptors (auth tokens, retries, logging), switch — but don't pull axios into a project just for OpenAPI work.

Validation `Zod`. Optional, but the value is asymmetric. The cost — bundle size and a small parse latency per response — is bounded. The benefit — catching the moment a backend response stops matching documentation — compounds over time. Schemas are 1:1 with types via z.infer, so you don't end up with two parallel definitions to maintain.

Base URL empty. Generated functions emit relative paths by default. You wire the base URL in your fetch wrapper or proxy config, which is where it belongs in modern hosting.

Where you'll want to deviate

You're using `axios`. Switch the HTTP client option. The output structure stays the same; only the runtime layer changes.

You don't use TanStack Query. Skip the hooks tab. Take just the types and the client. Your custom data layer can sit on top of either.

Your spec uses external `$ref`. Local $ref works perfectly — recursive types and circular references resolve correctly. Cross-file or remote $ref is not supported; bundle your spec into a single document first with @apidevtools/swagger-cli or redocly bundle.

Your spec is OpenAPI 2.0 (Swagger). Run it through swagger2openapi first. The conversion is mechanical and produces a clean 3.0 document the generator handles natively.

Your team prefers a different validator. Zod is the default because it's the dominant runtime validator in the TypeScript ecosystem in 2026. If your codebase already uses Valibot or ArkType, generate plain TypeScript types only and write the validators by hand against the same shapes. The compile-time guarantees still align.

What still needs you

Generators don't do everything. A few things stay manual:

  • Authentication. The generated client doesn't know about your auth flow. Wrap it with a small interceptor or middleware that adds the right Authorization header — TanStack Query's queryFn is the natural place.
  • Error semantics. The default treats non-2xx as throwing, but your API may have a structured error envelope (RFC 7807, custom shape). Adapt the generated request() helper or the hooks' onError to map your domain errors.
  • Streaming responses. OpenAPI 3.2 added itemSchema for sequential media types (covered in the 3.2 release post). The generator emits the response shape; consuming the stream is application code.
  • File uploads. Multipart bodies are emitted, but you'll usually want a tighter wrapper around FormData than the default.

A representative example

Take this minimal spec:

openapi: 3.0.0
info: { title: My API, version: 1.0.0 }
paths:
  /users/{id}:
    get:
      operationId: getUser
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          content:
            application/json:
              schema: { $ref: '#/components/schemas/User' }
components:
  schemas:
    User:
      type: object
      required: [id, email]
      properties:
        id: { type: string, format: uuid }
        email: { type: string, format: email }
        name: { type: string }

You get back, in roughly that order:

// types.ts
export interface User {
  id: string;
  email: string;
  name?: string;
}

// schemas.ts
export const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  name: z.string().optional(),
});

// client.ts
export function getUser(id: string): Promise<User> {
  return request<User>(`/users/${id}`);
}

// hooks.ts
export function useGetUser(id: string) {
  return useQuery({
    queryKey: ['getUser', id],
    queryFn: () => getUser(id),
  });
}

Drop those four files into src/api/. Import what you need in the components. Done.

Where this connects

[OpenAPI 3.2 added itemSchema for streaming](/blog/openapi-3-2-streaming-tags-query-method); the generator emits the right TypeScript shape for those responses too. Drizzle vs Prisma in 2026 talks about the database side of this same loop — if you're spec-first on the API and schema-first on the database, the whole codebase starts to type itself.

References

Source / further reading: OpenAPI 3.x specification