I’ve spent enough time debugging API mismatches at 2 AM to know that type safety matters. Not just at compile time, but all the way through to runtime, across your entire microservices architecture. When your frontend client doesn’t match your backend schema, the cost is real: wasted debugging cycles, production incidents, and friction between teams.
This article covers the practical patterns I’ve used to eliminate that friction. We’ll work through code generation, runtime validation, and the patterns that keep types synchronized across distributed teams. These aren’t theoretical ideas. They’re patterns you can implement today.
Why Type Safety Breaks in Microservices
In a monolith, you control everything. Change a function signature and the compiler catches it everywhere. In microservices, that safety net disappears. Your backend team deploys a new endpoint. Your frontend team doesn’t know about it yet. Someone adds a required field to a response schema and forgets to update the client types. A year later, someone changes an endpoint but doesn’t version it properly.
These aren’t edge cases. They happen constantly in distributed teams. The solution isn’t to be more careful. It’s to automate the safety.
The Three Layers of Type Safety
Real type safety in microservices requires three layers working together:
- Compile-time type checking (TypeScript)
- Runtime validation (Zod, io-ts)
- Automated code generation (OpenAPI, tRPC, ts-rest)
Compile-time checking alone isn’t enough. Your types could be wrong. Runtime validation alone is verbose and error-prone. Code generation alone creates brittle artifacts. Together, they form a complete safety system.
Starting with Code Generation from OpenAPI
OpenAPI is the standard way to document REST APIs. It’s language-agnostic, widely supported, and it’s the single source of truth for your API contract. The strategy is simple: generate your client types from your OpenAPI spec instead of writing them by hand.
For TypeScript, the modern approach is openapi-typescript, a lightweight tool that generates types in milliseconds without requiring a Java runtime or complex setup:
npm install openapi-typescript openapi-fetch
Point it at your OpenAPI spec and generate types:
npx openapi-typescript ./schema.yaml -o ./src/lib/api/schema.d.ts
This produces a TypeScript file with types for every endpoint, parameter, and response in your spec. No code generation overhead. No Java. Just types.
The generated types look like this:
// Generated automatically from your OpenAPI spec
export interface paths {
'/users': {
get: {
responses: {
200: {
content: {
'application/json': User[];
};
};
};
};
post: {
requestBody: {
content: {
'application/json': CreateUserRequest;
};
};
responses: {
201: {
content: {
'application/json': User;
};
};
};
};
};
'/users/{id}': {
get: {
parameters: {
path: { id: string };
};
responses: {
200: {
content: {
'application/json': User;
};
};
};
};
};
}
export interface components {
schemas: {
User: {
id: string;
email: string;
name: string;
createdAt: string;
};
CreateUserRequest: {
email: string;
name: string;
};
};
}
Now use openapi-fetch, a tiny fetch wrapper (about 6 KB), to consume these types:
import { createClient } from 'openapi-fetch';
import type { paths } from './schema';
const client = createClient<paths>({
baseUrl: 'https://api.example.com',
});
// Fully typed. URL, params, body, and response are all checked.
const { data, error } = await client.GET('/users/{id}', {
params: { path: { id: '123' } },
});
if (error) {
console.error('Failed to fetch user:', error);
} else {
// data is typed as User
console.log(data.name);
}
Every request is validated against your schema. The URL must match an endpoint. Path parameters must match. Query parameters must match. Response types are inferred. A typo is a compile error.
If you need a more full-featured client with hooks for TanStack Query or other patterns, tools like Orval can generate those too. But for most cases, openapi-typescript plus openapi-fetch is the minimal, modern path.
Adding Runtime Validation with Zod
Generated types are great, but they only exist at compile time. JavaScript doesn’t know about them. If your backend sends unexpected data, your frontend will accept it silently.
That’s where runtime validation comes in. Zod lets you define schemas that validate data at runtime and also generate TypeScript types from them:
import { z } from 'zod';
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1),
createdAt: z.coerce.date(),
});
type User = z.infer<typeof UserSchema>;
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
});
Now wrap your API calls with validation:
async function fetchUser(userId: string): Promise<User> {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
// Validate at runtime
const result = UserSchema.safeParse(data);
if (!result.success) {
throw new Error(`Invalid user data: ${result.error.message}`);
}
return result.data; // Fully typed and validated
}
If the backend sends something unexpected, you catch it immediately. No silent failures. No debugging sessions. You know exactly what went wrong.
Runtime validation is especially critical for third-party APIs you don’t control. A field rename or type change from an external API is silent at compile time but loud at runtime with Zod. A validation error in development is far cheaper than a production crash.
The pattern is simple: validate at the boundary. When data enters your system from an external source, check it. TypeScript types are a safety net, but runtime validation is the airbag.
Using tRPC for End-to-End Type Safety
If you control both your backend and frontend, tRPC offers something different: type safety without code generation. Your frontend automatically knows the exact shape of your backend procedures and their return types.
On the backend, define your API as procedures:
import { z } from 'zod';
import { publicProcedure, router } from './trpc';
export const appRouter = router({
users: router({
list: publicProcedure
.query(async () => {
return await db.user.findMany();
}),
create: publicProcedure
.input(z.object({ email: z.string().email(), name: z.string() }))
.mutation(async ({ input }) => {
return await db.user.create({ data: input });
}),
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return await db.user.findUnique({ where: { id: input.id } });
}),
}),
});
export type AppRouter = typeof appRouter;
On the frontend, use the router type to get full type safety:
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/router';
const trpc = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/trpc',
}),
],
});
// Fully typed. No schema to maintain separately.
const users = await trpc.users.list.query();
// TypeScript knows users is User[]
const newUser = await trpc.users.create.mutate({
email: 'alice@example.com',
name: 'Alice',
});
// TypeScript knows newUser is User
// This would be a type error:
// await trpc.users.create.mutate({ email: 'invalid-email' });
No code generation. No separate schema files. The backend is the source of truth, and types flow automatically to the frontend.
Handling Versioning and API Evolution
APIs change. The question is how you manage that change without breaking clients.
For OpenAPI-based APIs, version your spec explicitly:
openapi: 3.1.0
info:
title: User API
version: 2.0.0
paths:
/users:
get:
operationId: listUsers
responses:
'200':
description: List of users
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/User'
components:
schemas:
User:
type: object
required: [id, email, name, createdAt]
properties:
id:
type: string
email:
type: string
name:
type: string
createdAt:
type: string
format: date-time
When you make breaking changes, bump the version and maintain both endpoints for a deprecation period. Generate clients for both versions:
npx openapi-typescript ./schema-v1.yaml -o ./src/lib/api/v1.d.ts
npx openapi-typescript ./schema-v2.yaml -o ./src/lib/api/v2.d.ts
Your frontend can gradually migrate from v1 to v2:
// Gradually migrate to v2
import type { paths as PathsV1 } from './api/v1';
import type { paths as PathsV2 } from './api/v2';
const clientV1 = createClient<PathsV1>({
baseUrl: 'https://api.example.com',
});
const clientV2 = createClient<PathsV2>({
baseUrl: 'https://api.example.com',
});
// New code uses V2
const users = await clientV2.GET('/users');
// Old code uses V1 until you refactor it
const oldUsers = await clientV1.GET('/users');
This gives you breathing room. No sudden breaks. Teams migrate at their own pace.
Integrating with CI/CD Pipelines
Type safety is only effective if it’s enforced automatically. Add code generation to your CI/CD pipeline and validate that types stay in sync with your spec:
name: Generate API Client
on:
push:
paths:
- 'api/openapi.yaml'
jobs:
generate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Generate TypeScript types
run: |
npm install openapi-typescript
npx openapi-typescript ./api/openapi.yaml -o ./src/lib/api/schema.d.ts
- name: Check for drift
run: |
git diff --exit-code src/lib/api/schema.d.ts
- name: Validate types
run: npm run type-check
- name: Create PR if types changed
if: failure()
uses: peter-evans/create-pull-request@v4
with:
commit-message: 'chore: regenerate API types from OpenAPI spec'
title: 'chore: regenerate API types from OpenAPI spec'
body: 'API spec changed. Types have been regenerated. Review the changes.'
The key step is git diff –exit-code. If the generated types differ from what’s committed, the job fails. This catches three scenarios:
- A developer edited the spec but forgot to regenerate. CI regenerates, the file changes, the diff is non-empty, and the job fails before merge.
- The backend team deployed a spec change that breaks your contract. The diff shows exactly what changed.
- Your code no longer matches the new contract. TypeScript compilation fails, surfacing the breaking change in the PR.
Now when your backend team updates the OpenAPI spec, the frontend client regenerates automatically. A PR appears with the changes. Your frontend team reviews it, runs tests, and merges. No manual updates. No forgotten changes.
Error Handling with Type Safety
Type safety extends to error handling. Define your error responses in your schema:
const ErrorResponseSchema = z.object({
code: z.enum(['VALIDATION_ERROR', 'NOT_FOUND', 'UNAUTHORIZED', 'INTERNAL_ERROR']),
message: z.string(),
details: z.record(z.string()).optional(),
});
type ErrorResponse = z.infer<typeof ErrorResponseSchema>;
async function fetchUserSafe(userId: string): Promise<User | ErrorResponse> {
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
if (!response.ok) {
const error = ErrorResponseSchema.parse(data);
return error;
}
return UserSchema.parse(data);
} catch (error) {
return {
code: 'INTERNAL_ERROR',
message: 'Failed to fetch user',
};
}
}
// Usage
const result = await fetchUserSafe('123');
if ('code' in result) {
// It's an error
console.error(`Error: ${result.code} - ${result.message}`);
} else {
// It's a user
console.log(`User: ${result.name}`);
}
Now error handling is type-safe too. You can’t forget to handle an error case because TypeScript won’t let you.
Practical Integration Example
Here’s how these pieces fit together in a real application:
// api.ts: Your typed API client
import { z } from 'zod';
import { createClient } from 'openapi-fetch';
import type { paths } from './schema';
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1),
createdAt: z.coerce.date(),
});
type User = z.infer<typeof UserSchema>;
export class UserClient {
private client = createClient<paths>({
baseUrl: this.baseUrl,
});
constructor(private baseUrl: string) {}
async getUser(id: string): Promise<User> {
const { data, error } = await this.client.GET('/users/{id}', {
params: { path: { id } },
});
if (error) throw new Error(`Failed to fetch user: ${error}`);
// Validate runtime data against schema
return UserSchema.parse(data);
}
async listUsers(limit?: number): Promise<User[]> {
const { data, error } = await this.client.GET('/users', {
params: { query: { limit } },
});
if (error) throw new Error(`Failed to fetch users: ${error}`);
// Validate array of users
return z.array(UserSchema).parse(data);
}
}
// Usage in your component or service
const client = new UserClient('https://api.example.com');
try {
const users = await client.listUsers(10);
// users is User[]
console.log(users);
} catch (error) {
// Validation error or network error
console.error('Failed to load users:', error);
}
When to Use Each Approach
You don’t need all three patterns for every project. Choose based on your constraints:
- OpenAPI with openapi-typescript and openapi-fetch: You have a REST API with an OpenAPI spec. You want lightweight, zero-overhead type generation. You want to share types across multiple client languages.
- tRPC: You control both backend and frontend. You want maximum type safety with minimal overhead. You’re building a full-stack TypeScript application.
- ts-rest: You want OpenAPI-like contracts but with TypeScript-first definitions. You want code generation with more control over the generated client.
Most teams use a combination. OpenAPI for public APIs. tRPC for internal microservices. Runtime validation everywhere data enters your system.
Common Pitfalls and How to Avoid Them
Stale generated code is a real problem. If you generate once and forget, your types drift from reality. Make generation part of your standard workflow. Run it before every test. Commit generated files to version control so you can see what changed. Set up CI/CD to regenerate automatically and fail if the types drift.
Over-validating is tempting but creates performance issues. Validate at boundaries, not on every internal function call. If you control both sides of the API, trust your own types. If data comes from external sources or third-party APIs, validate it.
Under-specifying your API creates more problems than it solves. Your OpenAPI spec should be detailed. Required fields, field types, constraints, examples. The more information in your spec, the better your generated types and the fewer surprises at runtime.
Moving Forward
Type safety across microservices isn’t a feature you add later. It’s a foundation you build into your architecture from the start. The tools are mature. The patterns are proven. The investment pays off in reduced debugging time, fewer production incidents, and faster onboarding for new team members.
Start small. Pick one API. Set up openapi-typescript. Add runtime validation with Zod. Wire it into your CI/CD. Once it’s working, extend it to your other services. The compounding effect of type safety across your entire system is significant.
Your future self will thank you when you catch an API contract mismatch at compile time instead of in production.
What’s the difference between compile-time and runtime type safety?
Compile-time type safety (TypeScript) catches errors before you deploy. Your IDE tells you when types don’t match. Runtime validation happens when your code runs and checks that actual data matches your expectations. Both are necessary. TypeScript catches programmer mistakes. Runtime validation catches unexpected data from external APIs, databases, or user input.
Do I need code generation if I’m already using TypeScript?
TypeScript alone doesn’t prevent API contract mismatches. If you write your client types by hand and your backend team changes the API, your types become wrong. Code generation ensures your types always match your API spec. It’s the difference between manually keeping two documents in sync and automatically deriving one from the other.
Should I commit generated code to version control?
Yes. Commit generated files so you can see what changed in diffs. This makes code reviews easier and gives you a history of API changes. Don’t edit generated files by hand. If you need to modify them, change your spec or generation config instead.
Can I use these patterns with REST APIs I don’t control?
Yes, but you can only use runtime validation. You won’t have an OpenAPI spec to generate from. Write Zod schemas manually to validate the responses. It’s more work than code generation, but it still gives you runtime safety and better error handling.
How do I handle API versioning with type generation?
Version your OpenAPI spec explicitly. When you make breaking changes, bump the version number and create a new spec file. Generate clients for both versions into separate directories. Your frontend can gradually migrate from the old version to the new one without breaking existing code.