API Modeling & Contracts
Treat types as contracts between modules, services, and teams.
As systems grow, they're split into modules, services, and teams. Each boundary is a potential failure point: miscommunication, inconsistent data shapes, forgotten error cases. In dynamically typed languages, these issues surface at runtime — in production, under load, after deployment.
TypeScript offers a better way: types as contracts. By modeling your API surfaces with precise types, you move entire classes of integration bugs to compile time. In this chapter, you'll learn to design contracts that span module boundaries, model request/response cycles, separate domain logic from transport concerns, and build systems where the compiler enforces compatibility.
The Problem: Implicit Contracts
Consider a simple user service:
function getUser(id) {
// Returns { id, name, email } ... or null? undefined? throws?
}What's the contract? It's implicit, documented (maybe) in comments or external docs. Callers must guess:
- What does
idexpect? A number? String? UUID? - What's returned? An object?
null? Can it throw? - If it returns an object, what fields are guaranteed?
Implicit contracts break silently. A refactor changes the return shape, and distant callers fail at runtime.
Explicit Contracts with Types
Let's make the contract explicit:
Now the contract is clear:
- Input:
UserId(a string). - Output:
User | null(an object with three fields, ornullif not found).
If you change User's shape, every caller that accesses a field gets a compile error. The type is the source of truth.
Shared Type Modules
In multi-module or multi-service systems, types should live in a shared module that both producer and consumer depend on. This ensures they agree on the shape.
Both service.ts and client.ts import from types.ts. If you add a field to User, both sides see it immediately. If the service forgets to include it, the compiler complains.
Request/Response DTOs
In HTTP-like systems, you model requests and responses as Data Transfer Objects (DTOs). Even if you're not building a real network service, modeling synchronous functions this way clarifies intent.
This pattern:
- Decouples transport from logic: The handler is a pure function. You can swap HTTP, WebSocket, IPC, or in-process calls without changing the handler.
- Makes errors explicit: The response is a discriminated union. Callers must handle both success and error cases.
- Documents the contract: Anyone reading
types.tsknows exactly what the service expects and returns.
Generic API Response Envelopes
You can generalize the response pattern with a generic Result<T, E> or ApiResponse<T> type:
Now every handler returns ApiResponse<T>, and the client knows to check .status before accessing .data. This is a uniform interface — a contract that spans your entire API surface.
Separating Domain and Transport Layers
A well-architected system separates:
- Domain layer: Pure business logic and types (e.g.,
User,Order). - Application layer: Use cases that orchestrate domain logic (e.g.,
getUserById,placeOrder). - Transport layer: HTTP handlers, WebSocket listeners, CLI parsers — anything that talks to the outside world.
Types enforce these boundaries:
Here:
domain.tsknows nothing about HTTP orApiResponse— it just finds users.application.tswraps domain logic inApiResponse, making errors explicit.transport.tsserializes the response to JSON.
Each layer has a clear contract. If you swap HTTP for gRPC, only transport.ts changes. The domain and application layers are unaffected.
Challenge: Implement a Typed Order Service
You're building an order service. Implement the following:
- In
types.ts: DefineOrder(orderId: string, total: number) andApiResponse<T>. - In
service.ts: ImplementgetOrder(orderId: string): ApiResponse<Order>that returns a hardcoded order for"order-1", otherwise an error. - In
main.ts: CallgetOrderand print the result.
Success case: "Order order-1: $99.99"
Error case: "Error: Order not found"
Using Discriminated Unions for Multiple Response Types
Sometimes a single endpoint returns different shapes based on the request. Use discriminated unions:
The compiler ensures you check resp.type before accessing results. If you add a new response variant, exhaustiveness checking forces you to handle it.
Multiple Choice: Benefits of Shared Type Modules
What's the main benefit of placing types in a shared module (types.ts) that both client and server import?
Reduces code duplication
Ensures both sides agree on the contract at compile time
Improves runtime performance
Multiple Choice: Generic Response Envelopes
Given:
type ApiResponse<T> =
| { status: "ok"; data: T }
| { status: "error"; message: string };
function getUser(id: string): ApiResponse<User> { ... }
What must the caller do to access data?
Check data !== undefined
Check status === "ok" to narrow the discriminated union
Cast response.data as User
Summary
You've learned how to:
- Make contracts explicit with typed request/response shapes.
- Organize types in shared modules so producer and consumer agree.
- Model APIs with DTOs (request/response objects).
- Generalize responses with
ApiResponse<T>orResult<T, E>. - Separate domain, application, and transport layers using types to enforce boundaries.
- Use discriminated unions for endpoints with multiple response shapes.
Types as contracts shift entire classes of integration bugs leftward — from production to compile time. They're living documentation that never goes stale. In the next chapter, we'll apply these ideas to error handling, modeling failures as values rather than exceptions.
Next: Error Handling with Types — using Result<T, E> to make errors explicit and composable.