Type safety across your API is one of the best ways to prevent bugs and improve developer productivity. When your client and server share the same types, the compiler catches mismatches before they reach production. In this guide, I'll show you how to build truly type-safe APIs.
Why Type Safety Matters for APIs
The boundary between client and server is where most integration bugs happen. The client sends data in one format, the server expects it in another, and somewhere in between, things go wrong. Type safety helps prevent these issues by ensuring that both sides agree on the shape of the data.
When you share types between client and server, a change to an API endpoint's response format is caught immediately. The TypeScript compiler tells you exactly which client code needs to be updated. Without shared types, these mismatches are discovered only when the client crashes at runtime.
Define Clear DTOs from the Start
Data Transfer Objects (DTOs) define the shape of data that flows between client and server. Create explicit types for every request and response in your API. This makes the contract between client and server explicit and verifiable.
// User DTOs
interface CreateUserRequest {
email: string
name: string
password: string
}
interface UserResponse {
id: string
email: string
name: string
createdAt: string
}
interface UpdateUserRequest {
email?: string
name?: string
}
These types serve as documentation that is always up to date because it is the actual code. Anyone working on the client or server can look at these types and understand exactly what data to send and expect.
Validate at Runtime with Zod
TypeScript types are erased at runtime, so they cannot protect you from malformed data that arrives over the network. This is where runtime validation comes in. Libraries like Zod let you define schemas that validate data at runtime and infer TypeScript types from those schemas.
import { z } from 'zod'
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(2).max(100),
password: z.string().min(8)
})
type CreateUserRequest = z.infer<typeof CreateUserSchema>
With this approach, you define the validation rules once, and Zod generates the TypeScript type automatically. This eliminates duplication and ensures that your types and validation rules are always in sync.
When a request comes in, validate it against the schema before processing it. If validation fails, return a clear error response that tells the client exactly what went wrong.
Share Types Between Client and Server
The real power of type-safe APIs comes from sharing types between client and server. There are several ways to do this, and the best approach depends on your project structure.
Monorepo Approach
For monorepos, you can create a shared package that both client and server import from. This is the simplest approach and works well for most projects. The shared package contains your DTOs, validation schemas, and any utility types that both sides need.
Separate Repositories
For separate repositories, you can generate types from your API specification. Tools like openapi-typescript generate TypeScript types from OpenAPI specifications, keeping client and server in sync without sharing code directly.
Create Typed API Clients
A typed API client wraps your HTTP calls in functions that use your shared types. This gives consumers of your API compile-time safety and autocompletion.
class ApiClient {
private baseUrl: string
constructor(baseUrl: string) {
this.baseUrl = baseUrl
}
async createUser(data: CreateUserRequest): Promise<UserResponse> {
const response = await fetch(`${this.baseUrl}/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
if (!response.ok) {
throw new ApiError(await response.json())
}
return response.json()
}
async getUser(id: string): Promise<UserResponse> {
const response = await fetch(`${this.baseUrl}/users/${id}`)
if (!response.ok) {
throw new ApiError(await response.json())
}
return response.json()
}
}
With this client, calling an API endpoint is as simple as calling a function, and the TypeScript compiler ensures that you pass the right data and handle the right response type.
Handle Errors Consistently
Define a standard error format for your API and use it everywhere. This makes error handling predictable for clients and reduces the amount of code needed to handle failures.
interface ApiErrorResponse {
code: string
message: string
details?: Record<string, string[]>
}
class ApiError extends Error {
constructor(public response: ApiErrorResponse) {
super(response.message)
}
}
Clients can catch ApiError and handle it based on the error code, showing appropriate messages to users or retrying the operation.
Keep Contracts in Sync
As your API evolves, keeping client and server in sync becomes increasingly important. Use automated tests to verify that your server implementation matches your type definitions. If a test fails because the implementation does not match the types, you know immediately that something is out of sync.
For breaking changes, version your API and give clients time to migrate. Use deprecation warnings to notify clients of upcoming changes before they break.
Frequently Asked Questions
Do I really need runtime validation if I have TypeScript?
Yes. TypeScript types are erased at runtime, so they can't protect you from malformed network data, user input, or database records. Runtime validation fills this gap.
What's the best way to share types between client and server?
It depends on your project structure. For monorepos, a shared package is simplest. For separate repos, generate types from OpenAPI specs. Choose the approach that fits your workflow.
Should I use Zod or another validation library?
Zod is my go-to because it's TypeScript-first and has a great developer experience. Other options include Yup, Joi, and Valibot. Choose the one that fits your needs.
How do I handle API versioning with types?
Create separate type definitions for each API version. Use a version prefix in your shared types directory (e.g., v1/, v2/) and import the appropriate version in your client and server code.
What about GraphQL?
GraphQL has built-in type safety through its schema. Use tools like GraphQL Code Generator to generate TypeScript types from your GraphQL schema. The same principles apply: define your schema clearly and generate types from it.
The Bottom Line
Type-safe APIs are easier to maintain, safer to refactor, and more pleasant to work with than untyped alternatives. Define clear DTOs, validate at runtime with Zod, share types between client and server, create typed API clients, and handle errors consistently. These practices will help you build APIs that are reliable, maintainable, and a joy to integrate with.
Remember: type safety is not just about catching bugs—it's about improving the developer experience. When your types are correct, you get autocompletion, inline documentation, and refactoring support that makes development faster and more enjoyable.