Type-Safe APIs with OpenAPI and TypeScript
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.