API Contracts and Shared Models in TypeScript

Published Updated

An API contract is the agreement about what a request contains and what comes back. TypeScript is good at writing that agreement down in one place so every caller reads the same version, and it holds server code that imports the same contract to it too. What it cannot do is check the payload on the wire, or confirm the deployed server matches, because the type layer is gone before any response arrives.

Define the request and response types once, then validate the data you receive. This builds on TypeScript tooling and the TypeScript language basics.

What Do Shared TypeScript Types Guarantee?

Shared TypeScript types let the compiler check that your client and server code use compatible data shapes. They do not check the data sent over the network. When the form, client, route handler, and tests import the same type, renaming a field exposes incompatible uses during type checking.

Shared types do not inspect the response body. This function claims to return an Article without checking the data:

type Article = { id: string; title: string; wordCount: number };

async function loadArticle(id: string): Promise<Article> {
  const response = await fetch(`/api/articles/${id}`);
  return response.json() as Promise<Article>;
}

TypeScript's DOM declaration for response.json() resolves to any, and the assertion tells the compiler to treat that as an Article. No check happens, and other fetch implementations and wrappers that declare unknown or a generic type still check nothing at runtime either. If the endpoint renamed wordCount last week, or returned an error object, this function hands back something that is not an Article while every downstream file believes it is, and the failure surfaces somewhere far from the cause.

Shared types check your code. Runtime validation checks the data it receives.

How Do You Define a TypeScript API Contract?

Define a TypeScript API contract in a module that both client and server import, and include expected failures in the response type:

// contracts/articles.ts
export type CreateArticleRequest = {
  title: string;
  body: string;
  status: "draft" | "published";
};

export type CreateArticleResponse =
  | { ok: true; articleId: string }
  | { ok: false; error: "invalid-input" | "not-authorized" | "rate-limited" };
function describeResult(result: CreateArticleResponse): string {
  if (result.ok) {
    return `Created ${result.articleId}`;
  }

  switch (result.error) {
    case "invalid-input":
      return "Check the title and body.";
    case "not-authorized":
      return "Sign in to publish.";
    case "rate-limited":
      return "Too many requests, try again shortly.";
  }
}

console.log(describeResult({ ok: true, articleId: "a1" }));
// prints: Created a1

The discriminated union is doing the work. A caller cannot read articleId without first checking ok, and narrowing on result.error gives each failure reason its own case. With strictNullChecks enabled, exhaustiveness comes from the declared string return type: add a fourth reason and the uncovered path stops compiling, which is exactly the reminder you want.

Modeling expected failures as data rather than thrown exceptions is a choice. It fits outcomes the UI must render, such as invalid input or a rate limit. Unexpected faults such as a dropped connection can still throw exceptions. Callers should catch those errors and show an appropriate failure or retry state.

How Do You Validate an API Response?

Validate an API response by reading it as unknown and checking each required field before returning a typed value. A hand-written type guard can do this without a library:

function isArticle(value: unknown): value is Article {
  if (typeof value !== "object" || value === null) return false;

  const candidate = value as Record<string, unknown>;

  return (
    typeof candidate.id === "string" &&
    typeof candidate.title === "string" &&
    typeof candidate.wordCount === "number"
  );
}

async function loadArticle(id: string): Promise<Article> {
  const response = await fetch(`/api/articles/${id}`);
  if (!response.ok) {
    throw new Error(`Request failed: ${response.status}`);
  }
  const payload: unknown = await response.json();

  if (!isArticle(payload)) {
    throw new Error("Unexpected article payload");
  }

  return payload;
}

Two things changed from the earlier version. The payload is typed unknown, so nothing can use it until it has been checked, and the guard's value is Article return type is what narrows it afterwards. Invalid data now fails inside loadArticle, before another function tries to use it.

Hand-written guards are fine for a few endpoints and tedious past that, which is why most teams reach for a schema validation library. Those libraries typically let you declare the schema once and derive the TypeScript type from it, so you do not have to maintain the type and validation rules separately. Note that a guard like the one above returns its verdict on trust: the compiler does not verify that the body actually checks what the signature claims, so the logic inside is worth a test.

Generating Types from a Source of Truth

When a machine-readable schema already exists, derive the types instead of retyping them. The common paths:

  • OpenAPI: generate request and response types, or a whole client, from the specification the backend already publishes.
  • GraphQL: generate types from the schema plus your own documents, so each query gets a type matching the fields it selected.
  • Database schema: some query builders and ORMs derive row types from the schema or from migrations, which updates the row types when tables change.
  • Shared TypeScript: tools in the tRPC family export a router type inferred from server code and hand the client that type, which removes the generation step.

Generation moves the failure earlier: regenerate after a backend change and the compiler flags incompatible typed uses in the checked project. It also has costs. The output must be regenerated in CI or it goes stale, generated code is usually not something you edit by hand, and the inference-based tools need the client to see the server's exported router type, which across separate repositories means publishing it as a declaration package and keeping the versions aligned.

None of these replace validation for data crossing a network. A generated type describes what the schema says the server sends, not what a particular deployment sent this morning.

How Do You Change an API Contract Safely?

Change an API contract safely by preserving fields and behavior that existing clients rely on, or versioning incompatible changes. Adding an optional response field is usually safer than removing or renaming one.

A new failure-union member can break exhaustive consumers when they recompile, or deployed clients without a fallback. Test older clients before releasing the change. Your compiler checks the code in its project, not every deployed caller.

That last clause is the trap in a split deployment. A backend that renames a field breaks a frontend built from the previous contract, and nothing in either build reports it, because neither compiler sees the other side. Version the endpoint, keep the old field until the clients are updated, or ship both sides together.

Inside one workspace, the compiler is a reliable change detector. Deriving related shapes with Pick and Omit rather than copying them is what makes it reliable, as the utility types guide covers.

Pitfalls and Debugging

Casting the response instead of checking it. as Article and a typed generic fetchJson<Article> helper are both unchecked type claims unless the helper also validates the data. Neither inspects the payload. Type the raw payload unknown so the compiler makes you check.

Copying a contract file between repositories. The copy compiles perfectly and drifts silently, which is the worst combination. Publish a package, generate from a schema, or accept the risk explicitly and test the boundary.

A validator that is not the source of truth. If the schema and the TypeScript type are maintained separately, they can drift apart. Derive one from the other so a change has one place to happen.

Errors modeled two ways at once. Some endpoints throwing and others returning a failure branch forces every caller to handle both patterns. Pick one convention per API surface and document it next to the contract.

Frequently Asked Questions

Do shared TypeScript API types remove the need for validation?

Shared TypeScript API types do not replace runtime validation. They check the code at compile time, but a deployed server can return missing fields, incorrect values, or an older response format. Validate incoming data before using it.

Should TypeScript API types be generated or handwritten?

Generate TypeScript API types when a machine-readable schema already exists, such as an OpenAPI or GraphQL schema, because regeneration catches drift that a human copy will not. Handwrite them for small internal endpoints where the schema would be more ceremony than the endpoint, and keep a validator at the boundary either way.

Where should shared TypeScript API types live?

Put shared TypeScript API types in a package both client and server can import, or generate a client from the API schema for separate repositories. What goes wrong is copying a type file across repositories, since nothing then reports the two copies diverging.

How do you model an API error response in TypeScript?

Model expected API failures in TypeScript as members of a response union, such as invalid input or a rate limit. A discriminated union lets callers narrow safely, and it forces exhaustiveness only when the caller adds a mechanism such as an assertNever default or noImplicitReturns, which then errors at every call site needing an update.

Sources

  1. [1]
    Narrowing
    (typescriptlang.org)
  2. [2]
    Utility Types
    (typescriptlang.org)
  3. [3]
    Everyday Types
    (typescriptlang.org)