Type-Safe APIs with OpenAPI and TypeScript

9 min read
TypeScript OpenAPI API

You deploy your frontend. Everything works perfectly. Then the backend team changes a field name in the API. Your app breaks in production. Users complain. You scramble to fix it.

There's a better way.

The Problem with Manual Types

Most developers write TypeScript interfaces manually for their API responses. Something like this:

interface Product {
  id: number;
  name: string;
  price: number;
  description: string;
}

async function getProducts(): Promise {
  const res = await fetch('/api/products');
  return res.json();
}

This looks fine. But what happens when the backend changes price to priceInCents? Your TypeScript code compiles successfully, but your app crashes at runtime.

Aha moment: I once spent an entire afternoon debugging why product prices weren't displaying. The backend had changed the response format three days earlier. My manually written types were lying to me—they said everything was fine when it wasn't.

Manual types are documentation at best, and lies at worst. They drift from reality over time.

Enter OpenAPI

OpenAPI (formerly Swagger) is a standard for describing REST APIs. Your backend team writes a spec that defines every endpoint, request, and response.

Here's what a simple OpenAPI spec looks like:

openapi: 3.0.0
info:
  title: Products API
  version: 1.0.0

paths:
  /api/products:
    get:
      summary: Get all products
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Product'
        '500':
          description: Server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  
  /api/products/{id}:
    get:
      summary: Get product by ID
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Product'
        '404':
          description: Product not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

components:
  schemas:
    Product:
      type: object
      required:
        - id
        - name
        - price
      properties:
        id:
          type: integer
        name:
          type: string
        price:
          type: number
        description:
          type: string
        category:
          type: string
        inStock:
          type: boolean
    
    Error:
      type: object
      required:
        - message
      properties:
        message:
          type: string
        code:
          type: string

This spec is the single source of truth. It defines exactly what data the API returns, which fields are required, and what types they are.

Generating TypeScript Types

Instead of manually writing types, we'll generate them from the OpenAPI spec using openapi-typescript.

# Install the tool
npm install --save-dev openapi-typescript

# Generate types from your OpenAPI spec
npx openapi-typescript ./api-spec.yaml -o ./src/types/api.ts

This generates a TypeScript file with types for every endpoint, request, and response in your API. Here's what gets generated:

// Generated automatically - DO NOT EDIT
export interface paths {
  "/api/products": {
    get: {
      responses: {
        200: {
          content: {
            "application/json": components["schemas"]["Product"][];
          };
        };
        500: {
          content: {
            "application/json": components["schemas"]["Error"];
          };
        };
      };
    };
  };
  "/api/products/{id}": {
    get: {
      parameters: {
        path: {
          id: number;
        };
      };
      responses: {
        200: {
          content: {
            "application/json": components["schemas"]["Product"];
          };
        };
        404: {
          content: {
            "application/json": components["schemas"]["Error"];
          };
        };
      };
    };
  };
}

export interface components {
  schemas: {
    Product: {
      id: number;
      name: string;
      price: number;
      description?: string;
      category?: string;
      inStock?: boolean;
    };
    Error: {
      message: string;
      code?: string;
    };
  };
}

Notice description, category, and inStock are optional (marked with ?) because they weren't in the required array.

Building a Type-Safe API Client

Now let's use these generated types to create an API client that TypeScript will strictly enforce.

import type { paths, components } from './types/api';

type Product = components['schemas']['Product'];
type ApiError = components['schemas']['Error'];

// Helper type to extract response data
type ApiResponse = 
  paths[T][M] extends { responses: { 200: { content: { 'application/json': infer R } } } }
    ? R
    : never;

class ApiClient {
  private baseUrl: string;

  constructor(baseUrl: string = '') {
    this.baseUrl = baseUrl;
  }

  async getProducts(): Promise {
    const response = await fetch(`${this.baseUrl}/api/products`);
    
    if (!response.ok) {
      const error: ApiError = await response.json();
      throw new Error(error.message);
    }
    
    // TypeScript knows this returns Product[]
    const data: ApiResponse<'/api/products', 'get'> = await response.json();
    return data;
  }

  async getProductById(id: number): Promise {
    const response = await fetch(`${this.baseUrl}/api/products/${id}`);
    
    if (response.status === 404) {
      const error: ApiError = await response.json();
      throw new Error(`Product not found: ${error.message}`);
    }
    
    if (!response.ok) {
      const error: ApiError = await response.json();
      throw new Error(error.message);
    }
    
    // TypeScript knows this returns Product
    const data: ApiResponse<'/api/products/{id}', 'get'> = await response.json();
    return data;
  }
}

export const api = new ApiClient();

Now every API call is type-checked. If you try to access a field that doesn't exist, TypeScript catches it at compile time.

Using the Type-Safe Client

Let's see how this protects you in a real component:

import { api } from './api-client';

function ProductList() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    api.getProducts()
      .then(data => {
        setProducts(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, []);

  if (loading) return 
Loading...
; if (error) return
Error: {error}
; return (
{products.map(product => (

{product.name}

${product.price}

{product.description &&

{product.description}

} {product.inStock !== undefined && ( {product.inStock ? 'In Stock' : 'Out of Stock'} )}
))}
); }

TypeScript enforces everything. Try to access product.priceInCents? Compile error. Forget to check if description is undefined? TypeScript warns you.

What Happens When the API Changes?

This is where the magic happens. Let's say the backend team changes the spec:

Product:
  type: object
  required:
    - id
    - name
    - priceInCents  # Changed from 'price'
  properties:
    id:
      type: integer
    name:
      type: string
    priceInCents:    # Changed from 'price'
      type: integer
    # ... rest

You regenerate the types:

npx openapi-typescript ./api-spec.yaml -o ./src/types/api.ts

Now TypeScript immediately shows errors everywhere you reference product.price. You can't compile until you fix them all.

This is the key benefit: API changes break your build, not your production app. You catch issues at compile time instead of runtime.

Automating Type Generation

Don't regenerate types manually. Add it to your workflow:

// package.json
{
  "scripts": {
    "generate-types": "openapi-typescript ./api-spec.yaml -o ./src/types/api.ts",
    "prebuild": "npm run generate-types",
    "predev": "npm run generate-types"
  }
}

Now types regenerate automatically before building or starting your dev server. If your backend team updates the spec, you'll know immediately.

Pro tip: If your API spec is hosted at a URL (like https://api.example.com/openapi.yaml), you can generate types directly from it:

npx openapi-typescript https://api.example.com/openapi.yaml -o ./src/types/api.ts

Enforcing Path Parameters

OpenAPI also enforces path parameters. Here's a more robust version:

type PathParams = 
  paths[T][M] extends { parameters: { path: infer P } } ? P : never;

class ApiClient {
  async getProductById(
    params: PathParams<'/api/products/{id}', 'get'>
  ): Promise {
    // TypeScript enforces that params must have 'id: number'
    const response = await fetch(`${this.baseUrl}/api/products/${params.id}`);
    
    if (!response.ok) {
      const error: ApiError = await response.json();
      throw new Error(error.message);
    }
    
    return response.json();
  }
}

// Usage
const product = await api.getProductById({ id: 123 }); // ✓ Valid
const product = await api.getProductById({ id: '123' }); // ✗ Error: string not assignable to number
const product = await api.getProductById({}); // ✗ Error: property 'id' is missing

TypeScript now enforces that you pass the correct parameters with the correct types.

Common Mistakes to Avoid

Using any to bypass type errors: If your types don't match reality, fix the spec or fix your code. Don't bypass the safety.

Not regenerating types: Your types are only as good as your spec. Keep them in sync or you're back to manual types.

Committing generated files without reviewing: Always review type changes. They tell you what changed in the API.

Skipping optional field checks: Just because TypeScript lets you access product.description doesn't mean it exists at runtime. Check optional fields.

Aha moment: I once deployed code that assumed product.category was always present. It was optional in the spec, but I didn't check. The app crashed for products without categories. TypeScript warned me—I just ignored it.

Final Thoughts

Type-safe APIs aren't about being pedantic. They're about catching real bugs before they reach production. When your backend changes an API, you should know immediately—not when users start complaining.

OpenAPI gives you a contract. TypeScript enforces it. Together, they turn runtime errors into compile-time errors. That's worth the setup cost.

Start with your most critical API endpoints. Generate types. Build a type-safe client. You'll catch bugs you didn't know existed.

Your production app will thank you.