api-def

28 August 2026

TypeScriptAPI DesignOpen Source

When an HTTP error is an expected response

Opt in to typed HTTP response contracts with responsesOf, while keeping api-def’s default status handling for existing endpoints.

A missing user is an ordinary result of a lookup. But when an HTTP client throws for a 404, the code that handles that result ends up in a catch block alongside timeouts and connection failures.

A created resource returns 201. An accepted background job returns 202. A missing record returns 404. A duplicate submission returns 409. A validation problem might return 422. None of these necessarily means the request mechanism failed.

We wanted api-def to describe these outcomes directly, with a type for each response body.

In api-def 0.16, we added opt-in response contracts that let TypeScript narrow the data by HTTP status.

Status-based response contracts are opt-in. By default, api-def accepts 200–299 and 304; other HTTP statuses throw unless configured otherwise. Add responsesOf to an endpoint to declare exactly which statuses it should return as typed data, including outcomes such as 404. Upgrading alone does not change existing endpoints.

Exceptions are a poor model for expected outcomes

Consider a user lookup. The API has two documented responses:

  • 200: the user exists;
  • 404: the user does not exist.

The second result is not a network failure. The server received a valid request, understood it, and returned a meaningful answer. The caller often needs that answer as part of an ordinary branch in the product flow.

A client that throws for the 404 forces application code to recover domain data from an exception:

try {
const user = await getUser("user-123");
showProfile(user);
} catch (error) {
if (isNotFoundError(error)) {
showMissingUser();
return;
}

reportFailure(error);
}

This makes a normal product decision look like an operational failure. It also makes it easy to confuse a documented 404 with a timeout, a malformed payload, or an undocumented server response.

Opt in with responsesOf

Adding responsesOf opts this endpoint into status-based response handling. Its declared statuses replace the default acceptance rules and take precedence over acceptableStatus. Each declared status has its own response schema:

import { Api } from "api-def";
import * as zod from "zod";

const api = new Api({
baseUrl: "https://api.example.com",
name: "Users API",
});

export const getUser = api
.endpoint()
.paramsOf<"id">()
.responsesOf({
200: {
schema: zod.object({
id: zod.string(),
name: zod.string(),
}),
},
404: {
schema: zod.object({
code: zod.literal("not_found"),
}),
},
})
.build({
id: "get-user",
method: "get",
path: "/users/:id",
});

The endpoint no longer has one response type and a separate, loosely typed error path. Its result is a union discriminated by status.

Let the status narrow the data

TypeScript can now narrow the response without casts or custom error guards:

const response = await getUser.submit({
params: { id: "user-123" },
});

switch (response.status) {
case 200:
console.log(response.data.name);
break;

case 404:
console.log(response.data.code);
break;
}

Inside the 200 branch, data has the user shape. Inside the 404 branch, it has the documented error shape. If the contract adds another status later, the caller can handle it explicitly and use an exhaustive switch where that matters.

The status, runtime schema, and TypeScript type now come from the same definition.

ok still means what Fetch users expect

api-def responses also expose a Fetch-compatible ok property. It is true for successful HTTP statuses and false for statuses such as 404 or 409.

const response = await getUser.submit({
params: { id: "user-123" },
});

if (!response.ok) {
showMissingUser();
return;
}

showProfile(response.data);

ok: false can still be a declared response. It does not make the client throw.

Use ok when the product only needs a success/failure split. Use status when different outcomes carry different data or require different behaviour.

Validate the schema that belongs to the received status

Each declared response is validated against its own schema. If the server returns 404, api-def validates the payload as the 404 contract—not as the successful user response and not as an untyped error object.

That catches a particularly common integration failure: the status is documented, but the body has drifted.

// Declared contract
404: {
schema: zod.object({
code: zod.literal("not_found"),
}),
}

// Invalid server payload
{
"code": "missing"
}

The status is expected, but the response is not. Validation failure is therefore still exceptional.

Undeclared statuses remain failures

A typed contract should not silently widen itself to whatever the server happens to return. If the endpoint declares 200 and 404 but receives 201, api-def rejects the response as an invalid status.

This gives exceptions a narrower and more useful job:

  • the request could not reach the server;
  • the request was cancelled or timed out;
  • the server returned an undeclared status;
  • the payload failed the schema for its declared status;
  • middleware or response processing failed.

Those are operational or contract failures. They belong in error handling. A documented 404 does not.

Runtime validation is optional

Some codebases want status-aware TypeScript contracts without shipping a runtime schema library. The schema helper supports that form:

import { schema } from "api-def";

const deleteUser = api
.endpoint()
.paramsOf<"id">()
.responsesOf({
202: schema<{ jobId: string }>(),
409: schema<{ code: "deletion_in_progress" }>(),
})
.build({
id: "delete-user",
method: "delete",
path: "/users/:id",
});

The response still narrows by status, but the contract only exists at compile time. Teams can choose runtime validation for external or less trusted APIs and schema-free contracts where the boundary is already controlled.

Mocks should speak the same web-platform language

Status contracts are most useful when tests exercise the same shapes as production. api-def mock handlers receive a context object containing a standard WHATWG Request at context.request. They can return a standard Response:

const createUser = api
.endpoint()
.responsesOf({
201: {
schema: zod.object({ id: zod.string() }),
},
409: {
schema: zod.object({
code: zod.literal("duplicate"),
}),
},
})
.build({
id: "create-user",
method: "post",
path: "/users",
mocking: {
handler: () =>
Response.json(
{ code: "duplicate" },
{ status: 409 },
),
},
});

The mock does not need a special error channel. It returns the same status and body that the real server would, and the endpoint applies the same status discrimination and validation.

Consistency across request backends matters

Axios traditionally rejects many non-2xx responses before a client library can interpret them. Fetch returns those responses normally. If an API abstraction preserves that difference, endpoint behaviour changes when the underlying backend changes.

api-def now allows Fetch and Axios to return HTTP responses to the shared response pipeline. acceptableStatus and responsesOf are evaluated consistently after the backend responds, so the endpoint contract—not the transport library—decides which statuses are expected.

Switching backends no longer changes which responses throw.

What belongs in the catch block

For endpoints that opt in with responsesOf, the rules are:

  • Declared status and valid payload: return typed data.
  • Transport or contract failure: throw an exception.

With that opt-in, the caller can handle a missing user where it handles the rest of the lookup. The catch block is left for requests that could not complete or responses that broke the contract. Endpoints using the default behaviour still accept 200–299 and 304, and throw for other HTTP statuses unless configured otherwise.