Back to Blog
Tutorial

Generate a Type-Safe TypeScript SDK from Any OpenAPI Spec

2026-01-17·3 min read
typescriptopenapisdkdx

You're integrating a third-party API. The docs show a POST /users endpoint. You write this:

typescript
const response = await fetch('https://api.example.com/users', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${token}`,
  },
  body: JSON.stringify({
    name: 'John',
    emial: '[email protected]', // typo: "emial"
  }),
})

That typo compiles. TypeScript sees body as string and doesn't care. You deploy. Production breaks. The API returns 400 Bad Request and your users see "Something went wrong."

This is the cost of hand-written API clients: no compile-time safety, no autocomplete, no contract enforcement.

Why We Keep Doing This

Most APIs publish an OpenAPI spec. It's a machine-readable contract that defines every endpoint, parameter, and response type. But we ignore it and write fetch calls anyway because:

  1. OpenAPI feels like enterprise bloat — XML vibes, verbose JSON, scary tooling
  2. Generated code has a bad reputation — Java-style class hierarchies, unreadable output
  3. "It's just one endpoint" — until it's 47 endpoints and the backend team ships breaking changes

The thing is, modern generators have fixed these problems. The output in 2026 looks like code you'd write yourself. Clean, tree-shakeable, readable.

Picking a Generator

Not all generators are equal. Here's what matters:

Library Runtime Bundle Size Standout Feature Best For
@hey-api/openapi-ts Fetch ~2KB Modern output, plugin system New projects
openapi-fetch Fetch ~1KB Types only, zero runtime Minimal bundle
orval Axios/Fetch ~3KB React Query hooks, Zod schemas React apps
openapi-typescript-codegen Fetch/Axios ~2KB Mature, battle-tested General purpose
openapi-generator Any Varies 50+ language targets Polyglot teams

We'll use @hey-api/openapi-ts. It produces the cleanest output and handles modern TypeScript well.

How It Works

The flow is simple:

Your API publishes a spec. The generator reads it. You get types. TypeScript catches mistakes before they hit production.

No magic. Just code generation.

Generate the Client

Install it:

bash
pnpm add -D @hey-api/openapi-ts @hey-api/client-fetch

Point it at your OpenAPI spec. URL or local file, doesn't matter:

bash
npx @hey-api/openapi-ts \
  -i https://api.neat-pdf.com/functions/v1/public-neat-pdf-api/doc \
  -o src/lib/api \
  -c @hey-api/client-fetch

You get a src/lib/api/ folder:

text
src/lib/api/
├── index.ts          # Re-exports everything
├── client.ts         # Configured fetch client
├── types.gen.ts      # All request/response types
└── services.gen.ts   # Typed endpoint functions

Configure the Client

Set up the base URL and auth:

typescript
// src/lib/api/client.ts
import { createClient } from '@hey-api/client-fetch'

export const apiClient = createClient({
  baseUrl: import.meta.env.VITE_API_URL,
  headers: {
    Authorization: `Bearer ${import.meta.env.VITE_API_KEY}`,
  },
})

Use It

Now your API calls are typed:

typescript
import { htmlToPdf } from '@/lib/api'
import { apiClient } from '@/lib/api/client'

const { data, error } = await htmlToPdf({
  client: apiClient,
  body: {
    html: '<h1>Invoice #1234</h1>',
    paperWidth: 8.5,
    paperHeight: 11,
    marginTop: 0.5,
    // emial: 'typo' ← TypeScript error: 'emial' does not exist
  },
})

if (error) {
  // error is typed: 400 | 401 | 402 | 500
  console.error(error.message)
  return
}

// data is typed: Blob (PDF binary)
const url = URL.createObjectURL(data)

That typo from the beginning? Caught at compile time. Your IDE shows autocomplete for every field. Hover over paperWidth and you see the description from the spec.

Automate It

Your SDK should update when the API changes. Add a script:

json
{
  "scripts": {
    "generate:api": "openapi-ts -i https://api.neat-pdf.com/functions/v1/public-neat-pdf-api/doc -o src/lib/api -c @hey-api/client-fetch"
  }
}

Then set up a daily CI job:

yaml
# .github/workflows/sync-api.yml
name: Sync API Client
on:
  schedule:
    - cron: '0 0 * * *'
  workflow_dispatch:

jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v2
      - run: pnpm install
      - run: pnpm generate:api
      - name: Check for changes
        run: |
          if [[ -n $(git status --porcelain src/lib/api) ]]; then
            echo "API client changed"
            git add src/lib/api
            git commit -m "chore: regenerate API client"
            git push
          fi

Breaking changes in the API spec now show up as TypeScript errors in your next build. No more surprises in production.

How We Handle This at NeatPDF

We publish our OpenAPI spec at:

text
https://api.neat-pdf.com/functions/v1/public-neat-pdf-api/doc

Every endpoint, parameter, and error code is in there. When we add features or deprecate fields, the spec updates automatically.

We also have an official SDK on npm:

bash
pnpm add @neatpdf/sdk

But honestly? We encourage teams to generate their own. Here's why:

  1. You control the output — match your codebase conventions
  2. You control when to update — regenerate on your schedule, not ours
  3. You're not locked in — the spec works with any generator, any language

The spec is the contract. The SDK is just a convenience.


Hand-written API clients are technical debt disguised as simplicity. They skip validation, drift from the actual API, and turn typos into production incidents.

The generators have gotten good. The excuses haven't.

If your API doesn't publish an OpenAPI spec, ask them to. If you're building an API, publish one.

Try NeatPDF free →

Ready to generate PDFs?

Zero data retention. GDPR compliant.

Start Free TrialZero data retention · GDPR