typescript6 min read

Designing APIs With Types That Survive

API contracts that outlive refactors: versioning, evolution, shared types, and patterns that keep frontend and backend aligned without brittle coupling.

An API type that only exists in a Postman collection dies on the first rename. Surviving contracts live in code the compiler checks, change with explicit versioning rules, and treat breaking edits as product decisions—not accidental Friday deploys.

This article is for teams building HTTP or RPC APIs consumed by TypeScript clients (including Next.js apps). It builds on TypeScript for full-stack teams that ship, connects to Zod and runtime truth at the edges for request validation, and foreshadows backend topics like error contracts and auth sessions in this blog series.

Contracts are product promises

Every field in a public response is a promise to integrators, mobile apps, and your own frontend. The TypeScript handbook on everyday types shows how optional properties and unions express “may be absent” versus “one of these states.” API design should mirror user mental models: if cancelledAt is set, status should be "cancelled"—enforce that in types and validation, not only in docs.

Write a one-page contract note per resource: identifiers, stable sort keys, pagination cursor shape, and error codes. Link it from PRs when fields change.

Request and response symmetry

Asymmetric APIs confuse clients. If POST /users accepts { email, name } but returns a full User with nested organization, document why—and type both sides. Prefer consistent naming (snake_case versus camelCase) across JSON and TypeScript; pick one at the boundary and transform once.

For list endpoints, type pagination metadata explicitly:

type Page<T> = {
  items: T[];
  nextCursor: string | null;
};

Avoid untyped { data: any[] } patterns that force every consumer to guess. The TypeScript utility types page helps model partial updates (Partial<User>) versus replace semantics—critical for PATCH handlers.

Versioning without fear

Surviving APIs version in three layers:

  1. URL or header version — For large breaking changes (/v2/...).
  2. Additive JSON evolution — New optional fields; old clients ignore them.
  3. Typed deprecation — Mark fields @deprecated in OpenAPI or comments; remove after telemetry shows zero use.

Never repurpose a field’s meaning. If type meant “billing plan” and now means “notification channel,” add planType and migrate. Gradual typing strategies for growing codebases apply to APIs too: run dual readers during migration windows.

Single source of truth patterns

Pick one primary artifact:

  • Schema-first — OpenAPI or Zod schemas generate TypeScript types and docs.
  • Code-first — Hand-written types in a shared package imported by server and client.

Both work if CI fails when they diverge. Generating types from Zod with z.infer keeps runtime and compile-time aligned; see the Zod documentation for .safeParse at handlers and inferred output types for responses you control.

For Next.js Route Handlers, colocate schema + handler in the same module so reviewers see validation beside business logic.

Errors as part of the type story

Untyped errors become string matching in clients. Define a stable error envelope:

type ApiErrorBody = {
  error: {
    code: string;
    message: string;
    details?: Record<string, string[]>;
  };
};

Map HTTP status to code consistently. Frontend code switches on code, not substring searches in message. This pairs with SaaS auth flows where 401 versus 403 changes redirect behavior—types make those branches exhaustive in switch.

Idempotency and side-effect types

Payment and provisioning APIs need idempotency keys typed on requests. Webhook payloads should be unknown until verified and parsed—never trust shape from headers alone. Document signature verification outside the type system but parse into typed events immediately after.

Documentation that stays honest

Generated docs from schemas beat wiki pages. Include examples that typecheck in doc tests or snapshot tests. Link external references: MDN on fetch for client authors, TypeScript handbook for maintainers.

Internal links: when APIs back marketing or app surfaces, coordinate with SEO architecture for Next.js product sites for public JSON-LD versus private API separation.

Consumer-driven checks

The web app is your first API consumer. Generate or import types into the frontend package and let tsc fail when the server ships an undeclared field removal. Contract tests (Pact-style or golden JSON fixtures) catch changes Zod might miss if both sides update wrongly in the same PR.

Mobile or third-party consumers may lag; maintain compatibility tables in release notes.

Performance types matter too

List payloads affect UX. Types should encourage lean list DTOs versus detail DTOs:

  • UserSummary for tables
  • UserDetail for settings pages

Sharing a single bloated User type encourages over-fetching. Align with caching and revalidation choices—typed cache keys and revalidate tags should reference stable resource ids.

Security in the type model

Do not type secrets into responses. Use separate AdminUser and PublicUser types so accidental spread of internal fields fails compilation. Role-based views can be generics constrained by permission literals:

type ForRole<R extends "member" | "admin"> = R extends "admin" ? AdminView : MemberView;

Keep authorization in server code; types assist, they do not enforce alone.

Review checklist

  • Is every new field optional for old clients?
  • Are breaking changes versioned or flagged?
  • Do Zod schemas and exported types match?
  • Are list and detail shapes intentionally different?
  • Did we avoid avoiding any anti-patterns at JSON boundaries?

Pagination, filtering, and typed query strings

Query parameters arrive as strings. Typed APIs define parsers at the edge—often Zod z.object on URLSearchParams—before service layers see numbers or dates. Document defaults: if limit is omitted, is it 20 or 100? Type limit as a bounded integer in the schema, not as string | undefined deep in the stack.

Filter objects tempt open-ended Record<string, string>. Prefer explicit optional fields (status, ownerId, createdAfter) so OpenAPI and TypeScript stay honest. When you must support extensible filters for power users, version a filters bag separately from stable list params.

Webhooks and event catalogs

Event-driven integrations need a catalog type:

type DomainEvent =
  | { type: "invoice.paid"; payload: { invoiceId: string } }
  | { type: "user.deleted"; payload: { userId: string } };

Consumers switch on type with exhaustiveness checking. Adding an event is a semver minor if handlers ignore unknown types; major if you remove or repurpose payloads. Publish the catalog beside your HTTP OpenAPI so partners know both pull and push surfaces.

Time, money, and locale in types

Represent instants as ISO 8601 strings in JSON with documented timezone rules (UTC storage, local display). Type money as integer minor units plus currency code—never floating number for ledger fields. These conventions belong in the shared contract package so TypeScript for full-stack teams format helpers and API DTOs agree.

Migration playbooks

When renaming a field, ship a release that accepts both names on input and emits both on output for one cycle if clients are heterogeneous. Log usage of deprecated names. Remove only after metrics clear. Document the playbook in the team wiki and link from PR template checklists.

For database column renames, API types can lead the DB migration: API maps old column to new field name until cutover. Types in the mapper function make the temporary bridge obvious in review.

Closing

APIs with types that survive treat contracts as versioned product surface area, validate at the edge with schemas like Zod, and keep a single source of truth consumed by servers and TypeScript clients. Invest once in shared packages and CI checks; pay down ambiguity before it becomes production glue code and fragile as casts in the frontend.

Let's talk