An open API service indexing awesome lists of open source software.

https://github.com/usings/openapi-shape

Generate TypeScript API contract types from OpenAPI 3.x JSON.
https://github.com/usings/openapi-shape

dts openapi typescript

Last synced: about 1 month ago
JSON representation

Generate TypeScript API contract types from OpenAPI 3.x JSON.

Awesome Lists containing this project

README

          

# openapi-shape

[![npm version][npm-version-src]][npm-version-href]
[![npm downloads][npm-downloads-src]][npm-downloads-href]
[![bundle][bundle-src]][bundle-href]
[![License][license-src]][license-href]

Generate TypeScript declarations from OpenAPI 3.x JSON, plus optional typed request shapes for your own HTTP client.

Use `openapi-shape` when OpenAPI is your type contract, but your app should still own fetch/axios/ky/ofetch, auth, retries, caching, and response parsing.

## Quick Start

Run without installing:

```sh
npx openapi-shape ./openapi.json -o src/api.d.ts
```

Generate from a URL:

```sh
npx openapi-shape https://api.example.com/openapi.json -o src/api.d.ts
```

Install in a project when you regenerate types often:

```sh
pnpm add -D openapi-shape
pnpm exec openapi-shape ./openapi.json -o src/api.d.ts
```

Install as a runtime dependency only if you use the optional client:

```sh
pnpm add openapi-shape
```

Requires Node >= 22 and TypeScript >= 5.

## What It Generates

The generated file is plain TypeScript declarations:

```ts
export interface Endpoints {
"GET /pets": {
params: void
query: { limit?: number }
body: void
response: { "200": Schemas.Pet[] }
}
"POST /pets": {
params: void
query: void
body: Schemas.CreatePet
response: { "201": Schemas.Pet }
}
"GET /pets/{petId}": {
params: { petId: string }
query: void
body: void
response: { "200": Schemas.Pet }
}
}

export namespace Schemas {
export interface Pet {
id: number
name: string
}

export interface CreatePet {
name: string
}
}
```

Key ideas:

- `Endpoints` is keyed by `"METHOD /path"`.
- Each endpoint has `params`, `query`, `body`, and `response`.
- `response` is a map keyed by OpenAPI response keys such as `"200"`, `"404"`, or `"default"`.
- OpenAPI `components.schemas` are grouped under `Schemas`.
- `void` means that slot has no value.
- The file is safe to commit or regenerate in CI.

### Webhooks

OpenAPI 3.1 `webhooks` are emitted as a parallel `Webhooks` interface:

```ts
export interface Webhooks {
"POST pet.created": {
query: void
payload: Schemas.Pet
reply: void
}
}
```

Webhook entries use the receiving side's vocabulary:

- `payload` is the incoming request body.
- `reply` is the handler's outgoing response.
- `params` is omitted because webhook names do not have URL templates.
- `query` and `headers` describe what the third party sends.

Example handler type:

```ts
import type { Webhooks } from "./api"

function onPetCreated(payload: Webhooks["POST pet.created"]["payload"]) {
payload.id
}
```

## CLI

```text
USAGE openapi-shape [OPTIONS] --output=

ARGUMENTS

SOURCE Path to OpenAPI JSON file or HTTP(S) URL

OPTIONS

-o, --output= Output file path
--check Exit non-zero if --output is missing or stale
--headers Emit typed header parameters per endpoint/webhook
```

Typical package script:

```json
{
"scripts": {
"gen:api": "openapi-shape ./openapi.json -o src/api.d.ts",
"check:api": "openapi-shape ./openapi.json -o src/api.d.ts --check"
}
}
```

## Optional Client

`openapi-shape/client` gives you one typed request function over the generated `Endpoints` map. It builds adapter input; your adapter owns the HTTP call and response parsing.

```ts
import { createClient, type Adapter } from "openapi-shape/client"
import type { Endpoints } from "./api"

const adapter: Adapter = async ({ method, url, body, headers }) => {
const response = await fetch(url, { method, body, headers })
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`)
if (response.status === 204) return undefined
return response.json()
}

export const api = createClient(adapter, {
baseURL: "https://api.example.com",
})
```

Calls are checked at compile time:

```ts
const pets = await api("GET /pets", {
query: { limit: 10 },
})

const created = await api("POST /pets", {
body: { name: "Buddy" },
})
```

Client return types are inferred from response maps:

- `SuccessOf` is the union of all `2xx` entries.
- If there is no `2xx`, `SuccessOf` uses `default` only when it is the sole response key.
- `ResultOf` extracts one exact status key.

```ts
import type { ResultOf, SuccessOf } from "openapi-shape/client"
import type { Endpoints } from "./api"

type ListPets = SuccessOf
type NotFound = ResultOf
```

Adapter-specific options stay typed:

```ts
type AdapterOptions = { timeout?: number }

const adapter: Adapter = async ({ method, url, body, headers, options }) => {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), options?.timeout ?? 30_000)
try {
const response = await fetch(url, { method, body, headers, signal: controller.signal })
return response.status === 204 ? undefined : response.json()
} finally {
clearTimeout(timeout)
}
}

export const api = createClient(adapter, {
options: { timeout: 5000 },
})

await api("GET /pets", {
query: { limit: 10 },
options: { timeout: 1000 },
})
```

## Request Building

The optional client builds adapter input with these rules:

| Field | Behavior |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `method` | Read from the endpoint key, such as `GET /pets`. |
| `url` | `baseURL` plus path params and query string. Path params are URL-encoded. Query arrays become repeated keys, for example `tags=a&tags=b`. `null` and `undefined` query values are skipped. Absolute `http://` and `https://` endpoint paths bypass `baseURL`. |
| `body` | `undefined` stays `undefined`. `string`, `FormData`, `URLSearchParams`, `Blob`, `ArrayBuffer`, typed arrays, and `ReadableStream` pass through unchanged. Other defined bodies are JSON-stringified. |
| `headers` | JSON bodies get `content-type: application/json`. Passthrough bodies get no automatic content type. Per-call headers override automatic headers case-insensitively. Adapter headers use lowercase names. |
| `options` | Passed through to your adapter after default/per-call merging. Object options are shallow-merged; non-object options are replaced by the per-call value. |

Customize serialization when your API does not use the defaults:

```ts
export const api = createClient(adapter, {
baseURL: "https://api.example.com",
serializeQuery(query) {
const params = new URLSearchParams()
for (const [name, value] of Object.entries(query)) {
if (value == null) continue
params.set(name, Array.isArray(value) ? value.join(",") : String(value))
}
return params
},
serializeBody(body) {
if (typeof body === "string") {
return { body, headers: { "Content-Type": "text/plain" } }
}

return {
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
}
},
})
```

- `serializeQuery` receives the raw query object and returns a query string or `URLSearchParams`.
- `serializeBody` receives each non-`undefined` body and returns the adapter body plus optional headers.
- Per-call headers still override headers returned by `serializeBody`.

## Integration Examples

These examples are recipes. They map the same adapter input to fetch or third-party HTTP clients; keep auth, retries, hooks, and error handling in your adapter or HTTP client.

More complete fetch adapter

This version handles auth headers, typed HTTP errors, empty responses, and content-type based parsing:

```ts
import { createClient, type Adapter } from "openapi-shape/client"
import type { Endpoints } from "./api"

class HttpError extends Error {
constructor(
public readonly status: number,
public readonly body: string,
public readonly response: Response,
) {
super(`HTTP ${status} ${response.statusText}: ${body.slice(0, 200)}`)
this.name = "HttpError"
}
}

declare function getToken(): string

const adapter: Adapter = async ({ method, url, body, headers }) => {
const response = await fetch(url, {
method,
body,
headers: { ...headers, authorization: `Bearer ${getToken()}` },
})

if (!response.ok) {
const errorBody = await response.text()
throw new HttpError(response.status, errorBody, response)
}

const contentLength = response.headers.get("content-length")
if (response.status === 204 || contentLength === "0") {
return undefined
}

const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""
if (/^application\/(.*\+)?json/.test(contentType)) return response.json()
if (contentType.startsWith("text/")) return response.text()
return response.blob()
}

export const api = createClient(adapter, {
baseURL: "https://api.example.com",
})
```

For third-party adapters, use `Omit<...>` so callers cannot override fields owned by the generated request (`method`, `url`, `body`/`data`, `headers`).

axios adapter

```ts
import axios, { type AxiosRequestConfig } from "axios"
import { createClient, type Adapter } from "openapi-shape/client"
import type { Endpoints } from "./api"

type AdapterOptions = Omit

const adapter: Adapter = async ({ method, url, body, headers, options }) => {
const response = await axios.request({ ...options, method, url, data: body, headers })
return response.data
}

export const api = createClient(adapter)
```

ky adapter

```ts
import ky, { type Options as KyOptions } from "ky"
import { createClient, type Adapter } from "openapi-shape/client"
import type { Endpoints } from "./api"

type AdapterOptions = Omit

const adapter: Adapter = async ({ method, url, body, headers, options }) => {
return ky(url, { ...options, method, body, headers }).json()
}

export const api = createClient(adapter)
```

ofetch adapter

```ts
import { ofetch, type FetchOptions } from "ofetch"
import { createClient, type Adapter } from "openapi-shape/client"
import type { Endpoints } from "./api"

type AdapterOptions = Omit

const adapter: Adapter = async ({ method, url, body, headers, options }) => {
return ofetch(url, { ...options, method, body, headers })
}

export const api = createClient(adapter)
```

### Typed Query Keys

The generated `Endpoints` map can also type cache keys for libraries such as TanStack Query. `openapi-shape` does not generate hooks; keep cache policy, invalidation, and optimistic updates in your app.

TanStack Query example

```ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import type { Endpoints } from "./api"
import { api } from "./client"

type NonNullish = NonNullable
type EmptyObject = Record
type HasOnlyOptionalProps = T extends object ? (NonNullish extends T ? true : false) : false

type QueryKeyEndpoint = keyof Endpoints & string

type QueryKeyParamsPart = Endpoints[K]["params"] extends void
? NonNullish
: { params: Endpoints[K]["params"] }

type QueryKeyQueryPart = Endpoints[K]["query"] extends void
? NonNullish
: HasOnlyOptionalProps extends true
? { query?: Endpoints[K]["query"] }
: { query: Endpoints[K]["query"] }

type QueryKeyInput = QueryKeyParamsPart & QueryKeyQueryPart

type QueryKeyArgs = keyof QueryKeyInput extends never
? [input?: EmptyObject]
: NonNullish extends QueryKeyInput
? [input?: QueryKeyInput]
: [input: QueryKeyInput]

export const apiKeys = {
/** Endpoint-level key for broad invalidation, e.g. all GET /pets queries. */
endpoint: (endpoint: K) => [endpoint] as const,
/** Request-level key including params/query input for exact query caching. */
request: (endpoint: K, ...[input]: QueryKeyArgs) =>
input === undefined ? ([endpoint] as const) : ([endpoint, input] as const),
}

export function usePets(limit?: number) {
const query = limit === undefined ? {} : { limit }

return useQuery({
queryKey: apiKeys.request("GET /pets", { query }),
queryFn: () => api("GET /pets", { query }),
})
}

export function useCreatePet() {
const queryClient = useQueryClient()

return useMutation({
mutationFn: (body: Endpoints["POST /pets"]["body"]) => api("POST /pets", { body }),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: apiKeys.endpoint("GET /pets"),
})
},
})
}
```

## Programmatic API

Use the generator directly from build scripts, custom CLIs, or tests:

```ts
import { generate } from "openapi-shape"
import { writeFile } from "node:fs/promises"

const code = await generate("./openapi.json", {
headers: true,
formats: { "date-time": "Date", "uuid": "UUID" },
})

await writeFile("src/api.d.ts", code)
```

`generate(source)` is async for file paths and URLs. `generate(doc)` is synchronous for already-parsed OpenAPI objects:

```ts
import { generate } from "openapi-shape"

const code = generate(openapi)
```

Options:

| Option | Default | Description |
| --------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `formats` | `{}` | Maps OpenAPI `format` values to raw TypeScript type expressions. Applies to schemas with `type: "string" \| "number" \| "integer"` and nullable variants such as `["string", "null"]`. User mappings override the built-in `binary`/`byte` -> `Blob`. |
| `headers` | `false` | Adds a typed `headers` field to each endpoint/webhook from `in: header` parameters. When false, callers may still pass arbitrary runtime headers through the client. |

## Supported OpenAPI Features

OpenAPI 3.0 and 3.1 JSON documents are supported.

| Feature | Output / behavior |
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| `components.schemas` | Named declarations in `export namespace Schemas`, emitted as `interface` for object models or `type` for aliases/unions/primitives. |
| Schema `$ref` | Named TypeScript references to `Schemas.*`; schema refs are preserved instead of inlined. |
| Component `$ref` | `$ref` parameters, request bodies, responses, and path items are resolved before endpoint generation. |
| `oneOf` / `anyOf` | TypeScript unions, with duplicate members collapsed where possible. |
| `allOf` | TypeScript intersections. |
| `discriminator` on `oneOf` / `anyOf` | Required string literal discriminator properties injected into branch schemas for narrowable unions. |
| `enum` / `const` | Literal types and literal unions. |
| OpenAPI 3.0 `nullable` | Normalized to OpenAPI 3.1-style nullable type arrays. |
| OpenAPI 3.1 `type: ["T", "null"]` | TypeScript unions with `null`. |
| Arrays and tuples | `items` becomes arrays; `prefixItems` becomes tuples, with optional rest from `items`. |
| `additionalProperties` | `Record` for dictionary objects, or an index signature on objects with explicit properties. |
| `patternProperties` | Folded into the same index signature; multiple patterns become a union of value types. |
| OpenAPI 3.1 `webhooks` | A parallel `Webhooks` interface with `payload` and `reply` entries. |
| `requestBody.required` | Missing or `false` emits `body?: T`; `true` emits `body: T`. |
| Response maps | Responses are emitted as maps keyed by OpenAPI response keys, e.g. `"200"`, `"4XX"`, and `"default"`. |
| Client success type | `SuccessOf` is the union of all `2xx` response entries; when there is no `2xx`, sole `default` is used as the fallback success type. |

Identifier handling:

- Invalid object property and parameter names are quoted, for example `"user-id"?: string`.
- Invalid or reserved schema names are sanitized, for example `User-Profile` -> `User_Profile` and `class` -> `_class`.
- Schema name collisions after sanitization throw an error.

## Not Supported Yet

- Swagger 2.0. Convert to OpenAPI 3 first.
- YAML input.
- `readOnly` / `writeOnly` request and response variants.
- External `$ref` targets such as remote URLs or separate files.

## License

[MIT](./LICENSE) License

[npm-version-src]: https://img.shields.io/npm/v/openapi-shape?style=flat&colorA=080f12&colorB=1fa669
[npm-version-href]: https://npmx.dev/package/openapi-shape
[npm-downloads-src]: https://img.shields.io/npm/dm/openapi-shape?style=flat&colorA=080f12&colorB=1fa669
[npm-downloads-href]: https://npmx.dev/package/openapi-shape
[bundle-src]: https://img.shields.io/bundlephobia/minzip/openapi-shape?style=flat&colorA=080f12&colorB=1fa669&label=minzip
[bundle-href]: https://bundlephobia.com/result?p=openapi-shape
[license-src]: https://img.shields.io/github/license/usings/openapi-shape.svg?style=flat&colorA=080f12&colorB=1fa669
[license-href]: https://github.com/usings/openapi-shape/blob/main/LICENSE