Type API Contracts in TypeScript
Learn how to type API request and response contracts in TypeScript so the compiler catches mismatches between your client and server before they reach production.
TypeScript API contracts are typed agreements between your client and server. They say: for this endpoint, the request body must have these exact fields, and the response will have these exact fields. When either side breaks the contract, TypeScript tells you at compile time instead of when a production error wakes you up.
Without typed contracts, client and server drift apart. A developer renames a field on the server. The client keeps sending the old name.
The server accepts it silently because JavaScript is forgiving, but stores nothing. The bug surfaces days later. With typed contracts, the compiler catches the mismatch the moment either side changes.
Define a Contract
A contract is a set of TypeScript types that describe one endpoint. Start with the pieces every endpoint has: a request body, path parameters, and a response body. Define them all in one file:
// contracts/users.ts
type CreateUserRequest = {
email: string;
name: string;
};
type CreateUserResponse =
| { status: "ok"; user: { id: string; email: string; name: string } }
| { status: "error"; message: string };The response uses a discriminated union so the client must handle both success and error cases. The status field is the discriminant that TypeScript uses to narrow the type. On the client side, a simple if-check gives you full type safety:
const result = await createUser({ email: "a@b.com", name: "Alice" });
if (result.status === "ok") {
console.log(result.user.id);
} else {
console.error(result.message);
}Inside the if branch, TypeScript knows the user object exists. Inside the else branch, it knows the message field exists. You cannot access the wrong field in the wrong branch.
Now place these contracts in a shared package that both client and server import. The server implements the contract by declaring its return type matches:
import type { CreateUserRequest, CreateUserResponse } from "@acme/api-contracts";
async function handleCreateUser(body: CreateUserRequest): Promise<CreateUserResponse> {
if (!body.email.includes("@")) {
return { status: "error", message: "Invalid email" };
}
const user = await db.insertUser(body);
return { status: "ok", user };
}The client consumes the same contract through a typed fetch wrapper. TypeScript checks that the request body matches and that the caller handles the full response shape:
import type { CreateUserRequest, CreateUserResponse } from "@acme/api-contracts";
async function createUser(body: CreateUserRequest): Promise<CreateUserResponse> {
const res = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return res.json();
}If the server adds a new field to the response, the client code that accesses that field only compiles after the type is updated everywhere. The compiler forces the sync.
Contracts Are Compile-Time Only
A contract type describes what should happen. It does not guarantee that the data arriving at runtime actually matches, since a misconfigured proxy or a legacy client could still send garbage.
This is why contracts must be paired with runtime validation at the server boundary.
Use a schema validation library to define the contract and derive the TypeScript type from it. The schema becomes the single source of truth:
import { z } from "zod";
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
});
type CreateUserRequest = z.infer<typeof CreateUserSchema>;The server handler validates every request against the schema before passing it to business logic, so the domain code never receives untrusted data:
async function handleCreateUser(raw: unknown): Promise<CreateUserResponse> {
const parsed = CreateUserSchema.safeParse(raw);
if (!parsed.success) {
return { status: "error", message: "Invalid request body" };
}
const user = await db.insertUser(parsed.data);
return { status: "ok", user };
}The raw unknown input enters at the boundary. The schema validates and narrows it to the contract type, and after validation the rest of your code works with known types.
For more on this pattern, see validate API data in TypeScript.
Type Path Parameters and Query Strings
An endpoint like a GET request with path and query parameters has additional shapes to type. Define them alongside the response type in the contract file:
type GetUserParams = { id: string };
type GetUserQuery = { include?: "posts" | "orders" };
type GetUserResponse =
| { status: "ok"; user: { id: string; email: string } }
| { status: "error"; message: string };On the server, the handler accepts typed params and query objects instead of raw strings pulled from the URL. The signature below returns the same GetUserResponse union defined earlier, so it has to handle both the found and not-found cases:
async function handleGetUser(
params: GetUserParams,
query: GetUserQuery
): Promise<GetUserResponse> {
const user = await db.findUser(params.id, query.include);
if (!user) return { status: "error", message: "User not found" };
return { status: "ok", user };
}The compiler checks that params.id and query.include match the contract, and that every return path satisfies GetUserResponse. A typed client wrapper builds the URL from the same types, so the caller cannot pass the wrong params or ignore part of the response shape.
Keep Contracts Separate from Domain Types
A contract describes what travels over the wire. A domain type describes what your business logic needs. They diverge quickly.
A domain User type might have a hashedPassword field that must never appear in an API response. An API response might include pagination links that the domain does not need.
The fix is explicit mapping at the boundary with a dedicated function:
// Domain type
type User = { id: string; email: string; hashedPassword: string };
// Contract type
type UserResponse = { id: string; email: string };
// Boundary mapper
function toUserResponse(user: User): UserResponse {
return { id: user.id, email: user.email };
}This mapping is a single place where sensitive fields are stripped. If someone adds a new sensitive field to the domain type, the compiler does not complain because the mapper function explicitly selects which fields to include. The default is safety.
For more on organizing shared types, see share types across a TypeScript codebase.
Common Mistakes
Exposing domain types directly in API responses. A domain type with internal fields like hashed passwords or database timestamps must never appear in a response contract. Always map to dedicated response types.
Importing server code from the contract package. Contracts should contain only types. If the client imports the contract and pulls in Express or a database driver transitively, the package structure is wrong.
Forgetting that contracts are not runtime checks. The fetch function returns data typed as any by default. Without a schema validator at the server boundary, the contract is a hope, not a guarantee.
Duplicating contract types across client and server. Two copies of the same type inevitably drift apart. One shared source imported by both sides is the only way to stay in sync.
For more on building a typed fetch wrapper, see build a typed fetch wrapper.
Rune AI
Key Insights
- API contracts are shared TypeScript types that describe request bodies, path params, query params, and response bodies for each endpoint.
- Keep contract types in a shared package imported by both client and server.
- Use import type on the client so no server runtime code leaks into the bundle.
- Contracts are compile-time only. Runtime validation at the server boundary is still necessary.
- Never expose domain model types directly as API responses. Map between them.
Frequently Asked Questions
What is an API contract in TypeScript?
Should API contracts use the same types as my domain models?
Conclusion
Typed API contracts turn every endpoint into a compile-time agreement. When the client and server share a contract type, the compiler catches mismatched fields, missing properties, and type changes before anyone deploys. Keep contract types separate from domain types, use import type on the client, and validate runtime data at the boundary.
More in this topic
TypeScript Compiler Explained
The TypeScript compiler (tsc) turns .ts files into .js files while checking types. Learn what it does, how to use it, and how it fits your workflow.
Set Up ESLint for TypeScript
Install and configure ESLint with typescript-eslint to catch bugs and enforce consistent code style in your TypeScript project.
Pick Utility Type in TypeScript
Pick creates a new type by selecting only the properties you name from an existing type. Use it to build focused types for specific views, API responses, or function parameters.