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.
- Host: GitHub
- URL: https://github.com/usings/openapi-shape
- Owner: usings
- License: mit
- Created: 2026-04-26T16:52:50.000Z (2 months ago)
- Default Branch: main
- Last Pushed: 2026-05-15T13:25:08.000Z (about 2 months ago)
- Last Synced: 2026-05-15T15:20:04.075Z (about 2 months ago)
- Topics: dts, openapi, typescript
- Language: TypeScript
- Homepage:
- Size: 198 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
- Codeowners: .github/CODEOWNERS
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