Model a TypeScript API Contract: Requests, Responses, and the Network Boundary
Type a user-update API contract across client and server, and validate parsed payloads where they cross the network.

A TypeScript API contract is useful when it makes the handler and client disagree before a request is sent. It cannot make a network payload conform. For a user-update endpoint, keep those jobs separate: define the shapes application code shares, then check data arriving on each side of the wire.
Define the endpoint’s shapes
This example updates a user at PATCH /users/:id. The path supplies the ID; the body can supply a name, an active flag, or neither. The response has a status field that distinguishes a user from an error message.
type UpdateUserParams = { id: string };
type UpdateUserRequest = {
name?: string;
active?: boolean;
};
type User = {
id: string;
name: string;
active: boolean;
};
type UpdateUserResponse =
| { status: "ok"; user: User }
| { status: "error"; message: string };
type UpdateUserHandler = (
params: UpdateUserParams,
body: UpdateUserRequest
) => Promise<UpdateUserResponse>;
The optional properties mean an empty update body fits this type. They do not establish whether an empty update is useful, whether a name must be nonempty, or whether unlisted properties should be refused. Those are endpoint decisions to represent in validation if they matter. TypeScript’s interface documentation describes type checking in terms of a value’s shape and explains that optional properties may be absent.
The union tells a caller which fields belong to which outcome. It does not specify HTTP status codes or make the server return either shape. Here, the client will inspect the JSON discriminator rather than infer it from the HTTP response.
Check the values that arrive from the network
A handler receives values decoded by routing and JSON parsing, not values created by a TypeScript call site. Treat them as unknown until checked. These small guards accept only the modeled property types; they deliberately permit additional properties and an empty update body.
function isUpdateUserRequest(value: unknown): value is UpdateUserRequest {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return false;
}
if ("name" in value && typeof value.name !== "string") return false;
if ("active" in value && typeof value.active !== "boolean") return false;
return true;
}
function isUpdateUserResponse(value: unknown): value is UpdateUserResponse {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return false;
}
if (!("status" in value)) return false;
if (value.status === "error") {
return "message" in value && typeof value.message === "string";
}
if (value.status !== "ok" || !("user" in value)) return false;
const user = value.user;
if (typeof user !== "object" || user === null || Array.isArray(user)) {
return false;
}
return (
"id" in user && typeof user.id === "string" &&
"name" in user && typeof user.name === "string" &&
"active" in user && typeof user.active === "boolean"
);
}
A guard’s return type tells TypeScript what to assume when it returns true; TypeScript does not prove that its implementation checked enough. The boundary-validation example at Codewalkers makes that distinction explicit. Exercise both outcomes rather than trusting the predicate spelling:
function checkGuards(): void {
const cases = [
{ label: "empty update", actual: isUpdateUserRequest({}), expected: true },
{ label: "valid update", actual: isUpdateUserRequest({ active: false }), expected: true },
{ label: "bad update", actual: isUpdateUserRequest({ active: "no" }), expected: false },
{ label: "valid success", actual: isUpdateUserResponse({
status: "ok", user: { id: "u1", name: "Ada", active: true }
}), expected: true },
{ label: "valid error", actual: isUpdateUserResponse({
status: "error", message: "Not found"
}), expected: true },
{ label: "bad success", actual: isUpdateUserResponse({
status: "ok", user: { id: "u1", name: "Ada" }
}), expected: false },
{ label: "bad error", actual: isUpdateUserResponse({
status: "error", message: 404
}), expected: false }
];
for (const testCase of cases) {
if (testCase.actual !== testCase.expected) {
throw new Error(`Guard failed: ${testCase.label}`);
}
}
}
checkGuards();
These tests check the particular rules above, including both response variants. If the endpoint must reject extra fields or require at least one changed field, change the guard and add cases for those rules.
Use the checked contract on both sides
The following handler is framework-neutral. Its unknown arguments represent values from a route adapter; the adapter must pass its parsed values here rather than assert that they already match the contract. The inner handler is only a placeholder for application logic, not a persistence implementation.
const updateUser: UpdateUserHandler = async (params, body) => ({
status: "ok",
user: {
id: params.id,
name: body.name ?? "Existing name",
active: body.active ?? true
}
});
async function handleUpdate(
rawParams: unknown,
rawBody: unknown
): Promise<UpdateUserResponse> {
if (
typeof rawParams !== "object" || rawParams === null ||
!("id" in rawParams) || typeof rawParams.id !== "string" ||
!isUpdateUserRequest(rawBody)
) {
return { status: "error", message: "Invalid update request" };
}
return updateUser({ id: rawParams.id }, rawBody);
}
async function sendUpdate(
params: UpdateUserParams,
body: UpdateUserRequest
): Promise<UpdateUserResponse> {
const response = await fetch(`/users/${encodeURIComponent(params.id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const payload: unknown = await response.json();
if (!isUpdateUserResponse(payload)) {
throw new Error("Unexpected update response");
}
return payload;
}
async function showUpdate(): Promise<void> {
const result = await sendUpdate({ id: "u1" }, { name: "Ada" });
if (result.status === "ok") {
console.log(result.user.name);
} else {
console.error(result.message);
}
}
Checking status narrows the response to the branch with user or the branch with message, as described in this account of response unions. Meanwhile, the typed function boundaries catch incompatible application code. These lines are deliberately invalid and should not be added to a running example:
// Deliberately invalid: the success result lacks required User fields.
const badHandler: UpdateUserHandler = async () => ({
status: "ok", user: { id: "u1" }
});
// Deliberately invalid: active must be a boolean.
sendUpdate({ id: "u1" }, { active: "yes" });
Neither rejection establishes what a deployed endpoint sends. In particular, assigning parsed JSON directly to UpdateUserResponse with a type assertion would skip the response check; the unknown variable and guard make that check visible. A failed guard stops the unexpected shape before application code uses it as a declared response.
Decide where the contract lives
If an OpenAPI specification already describes the endpoint, use it as the authority and generate request and response types or a client from it. OpenAPI can describe endpoints and request and response schemas in a machine-readable form, and OpenAPI tooling can generate TypeScript types and clients. Regenerate after changing the specification, then inspect the resulting type or client changes and affected call sites. That checks artifact freshness and compile-time agreement—not whether a particular deployed server conforms.
Without an existing specification, a shared runtime schema from which both sides derive types can put the wire check and the application type under one definition. Choose a place both applications can consume, rather than copying a contract file into separate repositories: copies can diverge without either compiler noticing. Whichever source owns the contract, keep validation where requests and responses actually cross the network.


