Dataslope logoDataslope

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 id expect? 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:

Code Block
TypeScript 5.7

Now the contract is clear:

  • Input: UserId (a string).
  • Output: User | null (an object with three fields, or null if 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.

Code Block
TypeScript 5.7

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.

Code Block
TypeScript 5.7

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.ts knows 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:

Code Block
TypeScript 5.7

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:

  1. Domain layer: Pure business logic and types (e.g., User, Order).
  2. Application layer: Use cases that orchestrate domain logic (e.g., getUserById, placeOrder).
  3. Transport layer: HTTP handlers, WebSocket listeners, CLI parsers — anything that talks to the outside world.

Types enforce these boundaries:

Code Block
TypeScript 5.7

Here:

  • domain.ts knows nothing about HTTP or ApiResponse — it just finds users.
  • application.ts wraps domain logic in ApiResponse, making errors explicit.
  • transport.ts serializes 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

Challenge
TypeScript 5.7
Typed Order Service

You're building an order service. Implement the following:

  • In types.ts: Define Order (orderId: string, total: number) and ApiResponse<T>.
  • In service.ts: Implement getOrder(orderId: string): ApiResponse<Order> that returns a hardcoded order for "order-1", otherwise an error.
  • In main.ts: Call getOrder and 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:

Code Block
TypeScript 5.7

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

QuestionSelect one

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

QuestionSelect one

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> or Result<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.

On this page