API Contracts and Shared Models in TypeScript
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.
Holding both facts at once is the whole subject. This guide covers modelling a contract once, validating what actually shows up, generating types from a schema, and changing a contract without silently breaking callers.
What a Shared Type Actually Guarantees
A shared type is a compile-time agreement between the parts of your codebase that the compiler sees. That is genuinely valuable: the form, the client, the route handler, and the tests stop drifting apart, and renaming a field produces errors everywhere it is used.
What it does not do is check the wire. Consider the shape of the usual mistake:
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.
The rule to carry through the rest of this guide: the shared type keeps your code consistent with itself, and a runtime check is what makes it consistent with reality.
Modelling a Request and Response Once
Declare the contract in one module both sides import, and model the failure cases as part of the response rather than as exceptions:
// 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. 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.
Modelling expected failures as data rather than thrown exceptions is a choice, not a law. It fits outcomes the UI must render, such as invalid input or a rate limit. Genuine faults such as a dropped connection are still better as exceptions, since no branch of the UI is meant to explain them.
Validating at the Boundary
Parse once at the edge, then let the checked type flow inward. A hand-written guard shows the mechanism with no dependency:
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}`);
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. The failure is now loud and local instead of silent and distant.
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 the type and the check cannot disagree. 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 keeps models honest as 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 shows you every call site that no longer fits. It also has a cost worth naming. 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.
Changing a Contract Without Breaking Callers
Additive changes are the safer ones, though not uniformly. An optional response field is generally backward-compatible, while a new member of a failure union can break exhaustive consumers when they recompile, and already-deployed clients that have no fallback, so it wants a versioning policy. Removing or renaming a field is a breaking change, and the compiler will tell you about it in your own repository only.
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 the same claim wearing different clothes: 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 will disagree eventually. Derive one from the other so a change has one place to happen.
Errors modelled 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
If both sides share a type, do you still need validation?
Yes, whenever the data crosses a network. A shared type is checked when both sides are compiled, and the response arrives long after that, from a deployment that may be running older code. Validation is what turns an assumption into a checked fact; the shared type is what stops your own code disagreeing with itself.
Should API types be generated or handwritten?
Generate them when a machine-readable source of truth 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 types live?
In whichever place both sides can import without one depending on the other's internals: a shared package in a monorepo, or a generated client 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 error response?
As part of the response union rather than a thrown exception, when the failure is an expected outcome 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.
Related
- TypeScript Tooling for the full topic overview
- unknown, any, and Runtime Data for the validation techniques behind these boundaries
- TypeScript with React for the components that consume these responses
- TypeScript for the language guide and the full learning path
Sources
-
[1]
Narrowing(typescriptlang.org)
-
[2]
Utility Types(typescriptlang.org)
-
[3]
Everyday Types(typescriptlang.org)
Read Next
Why unknown beats any at the boundaries of a TypeScript app, how to validate runtime data such as parsed JSON, and what type assertions actually cost.
JSX settings, component prop types, children and native element props, typed state and refs, event handlers, and what types cannot enforce across the server and client boundary.
The half of TypeScript that is not types: tsconfig.json, the split between transpiling and type checking, React components, and API contracts between a frontend and its server.