Type-safe status dispatch for API clients with status-discriminated responses. Framework-agnostic. Zero runtime dependencies.
Documentation · Quick Start · API Reference
const org = await expectStatus(201, client.createOrganisation({ body: data }));
// `org` is typed as the body of the 201 branch — the discriminated
// success body, not `unknown` and not the union of all branches.Many OpenAPI codegens emit responses as status-discriminated unions (or can be adapted to one):
type CreateOrgResponse =
| { status: 201; body: Organisation }
| { status: 409; body: { message: string; organisationId: string } }
| { status: 422; body: { message: string; field: string } };That shape is great for type safety — but every call site ends up with the same boilerplate:
// ❌ Without expect-status — repeated at every call site
const res = await client.createOrganisation({ body: data });
if (res.status === 201) {
return res.body;
}
if (res.status === 409) {
redirect(`/org/${res.body.organisationId}`);
}
if (res.status === 422) {
throw new Error(res.body.message);
}
throw new Error("Unexpected status");// ✅ With expect-status — one line for the happy path
const org = await expectStatus(201, client.createOrganisation({ body: data }));expect-status eliminates this boilerplate with:
- Automatic type narrowing — the return type is the body of the success branch, not
unknown - Backend message bubbling — unhandled errors automatically extract
body.message, RFC 7807detail, and other common shapes - Flat dispatch — functions are handlers, strings are messages —
{ 409: "Conflict", 422: (body) => redirect() } - Returning handlers — handlers can return recovery values, not just throw
- Class-range catch-alls —
'4xx','5xx','success','error'for blanket matching - Negation —
'!4xx'matches anything except client errors - Instance-wide defaults — configure once (
401 → "Please sign in.") and reuse across every call - Observability hooks —
onError/onSuccessfor logging, metrics, Sentry — without coupling to your transport recovercatch-all — wraps the entire error path, returns a fallback value instead of throwingthrows: false— returns a typedSafeResult<T>instead of throwing- Custom groups —
groups: { auth: [401, 403] }for domain-specific status sets - Compile-time exhaustiveness — opt-in
exhaustive: trueensures every error status is handled
npm install expect-status
# or
pnpm add expect-status
# or
yarn add expect-statusRequires TypeScript 5.4+ for infer T extends U and template literal status-class inference. Node 18+.
import { expectStatus } from "expect-status";
// Simple — bubble the backend's body.message on any non-success status:
const org = await expectStatus(201, client.createOrganisation({ body: data }));
// Multiple success statuses (e.g. upsert, sync-or-async):
const result = await expectStatus([200, 201], client.upsert({ body: data }));
// ^? body of the 200 branch | body of the 201 branch
// Named specifiers:
const body = await expectStatus("success", client.health()); // any 2xx
const data = await expectStatus("!4xx", client.maybeRedirect()); // anything except 4xx
// Flat dispatch — strings are messages, functions are handlers:
await expectStatus(201, client.createOrganisation({ body: data }), {
409: "You already have an organisation.",
422: (body) => redirect(`/fix/${body.field}`), // handler can return a value
"5xx": "Service is having issues. Please retry shortly.",
});
// Recover from errors instead of throwing:
const result = await expectStatus(200, client.riskyCall(), {
recover: (err) => ({ fallback: true, reason: err.message }),
});
// Non-throwing mode — typed SafeResult<T>:
const safe = await expectStatus(200, client.fetch(), { throws: false });
if (safe.ok) {
console.log(safe.data); // typed body
} else {
console.error(safe.error); // ExpectStatusError
}For projects using native fetch (without a codegen client), the fetchExpect helper provides a thin wrapper that handles JSON parsing and status validation:
import { fetchExpect } from "expect-status/fetch";
type ItemResponse =
| { status: 200; body: { id: string; name: string } }
| { status: 404; body: { message: string } };
const item = await fetchExpect<ItemResponse>(
"https://api.example.com/items/1",
200,
{
404: "Item not found",
init: { headers: { Authorization: "Bearer token" } },
},
);
// ^? { id: string; name: string }The helper:
- Calls
fetchwith the URL and anyinitoptions - Parses the response as JSON
- Validates the status using
expectStatus - Returns the typed body on success
expect-status ships built-in presets for common clients. Import, plug in, done:
import { createExpectStatus, adapters } from "expect-status";
// Axios / Orval
const expectStatus = createExpectStatus({ adapter: adapters.axios });
// openapi-fetch / hey-api
const expectStatus = createExpectStatus({ adapter: adapters.openapiClient });
// Native fetch
const expectStatus = createExpectStatus({ adapter: adapters.fetch });| Preset | Maps | For |
|---|---|---|
adapters.axios |
{ status, data } → { status, body } |
Axios, Orval |
adapters.openapiClient |
{ data, error, response } → { status, body } |
openapi-fetch, hey-api |
adapters.fetch |
Response → { status, await json() } |
Native fetch (async) |
Or write your own for custom envelopes:
const expectStatus = createExpectStatus({
adapter: (res) => ({ status: res.meta.httpStatus, body: res.result.data }),
});The observability hooks (onError, onSuccess) are the recommended way to add cross-cutting concerns like logging, metrics, or error tracking without coupling the library to a specific framework.
Handlers (functions) are always checked before messages (strings), even across per-call and instance defaults:
- Per-call handler — most specific match (exact code → range → group).
- Instance default handler — most specific match.
- Per-call message — most specific match.
- Instance default message — most specific match.
extractMessage(body)— pulls a message from the response body.fallbackMessage— last-resort static string.onErrorfires once with the resolved error just before it's thrown.recover— true catch-all; if it returns a non-undefinedvalue, that value is the result instead of throwing.
Within each tier, exact codes shadow ranges, which shadow custom groups.
On success: onSuccess fires → transform reshapes body → return.
The default instance. Throws ExpectStatusError on non-success statuses with no handler, bubbling messages from common error-body shapes.
successStatus may be a single status code, a readonly array ([200, 201]), a named specifier ('success', 'error', '4xx'), a negated specifier ('!4xx'), or a mixed array ([200, '3xx']). The body type returned is the union of bodies for all matching branches.
Build a configured instance with your own error class, message extractor, fallback message, instance defaults, custom groups, adapter, and observability hooks.
import { createExpectStatus } from "expect-status";
class RequestError extends Error {}
export const expectStatus = createExpectStatus({
errorFactory: (message) => new RequestError(message),
fallbackMessage: "Something went wrong. Please try again.",
groups: {
auth: [401, 403],
retryable: [408, 429, 503],
},
defaults: {
auth: "Please sign in or check your permissions.",
"5xx": "Service is temporarily unavailable. Please retry shortly.",
},
onError: (err, response) =>
Sentry.captureException(err, {
extra: { status: response.status, body: response.body },
}),
});| Option | Type | Default | Description |
|---|---|---|---|
statusField |
string literal |
'status' |
Field on the response holding the numeric status. Override for envelope schemas like { code: 200; payload: T }. |
bodyField |
string literal |
'body' |
Field on the response holding the body payload. |
errorFactory |
(message, response) => Error |
new ExpectStatusError(...) |
Constructs the error thrown on non-success statuses. |
extractMessage |
(body: unknown) => string | undefined |
defaultExtractMessage |
Pulls a user-facing message from the body. See Composable extractors below. |
fallbackMessage |
string |
'Request failed with an unexpected status.' |
Used when no other source supplies a message. |
groups |
Record<string, number[]> |
{} |
Custom named status groups. E.g. { auth: [401, 403] }. Usable as expected status or dispatch keys. |
adapter |
(response: T) => { status: number; body: unknown} |
none | Normalizes non-standard response shapes (e.g. Axios { data }) before dispatch. Runs first. |
defaults |
Record<string | number, string | Function> |
{} |
Instance-wide default flat dispatch entries. Per-call dispatch shadows these. |
onError |
(error: Error, response) => void | Promise<void> |
none | Observability hook fired once per dispatched error. Errors inside the hook are swallowed. |
onSuccess |
(response) => void | Promise<void> |
none | Observability hook fired once when the status matches the success criteria. Errors inside the hook are swallowed. |
For envelope schemas with non-canonical field names, override statusField and bodyField:
type CodeResponse =
| { code: 200; payload: { id: string } }
| { code: 409; payload: { msg: string; orgId: string } };
const expectCode = createExpectStatus({
statusField: "code",
bodyField: "payload",
});
const result = await expectCode(200, res);
// ^? { id: string }
await expectCode(200, res, {
409: (body) => {
throw new Error(body.msg);
},
});The full feature set (multi-success, ranges, flat dispatch, defaults, exhaustive, onError) all work identically with custom field names. The default expectStatus and any prior createExpectStatus() calls without these options remain unchanged — 'status' and 'body' stay the defaults.
A runtime TypeError is thrown if the configured statusField doesn't hold a number on the response (catches malformed responses early).
defaultExtractMessage is composed from named primitives that each handle one body shape. Import them to build your own priority chain:
import {
createExpectStatus,
chainExtractors,
stringBody, // body itself, if non-empty string
messageField, // body.message
problemDetail, // RFC 7807 body.detail then body.title
arrayErrors, // Laravel-style body.errors[0].message or first element
springError, // Spring-style body.error (often just the HTTP status name)
} from "expect-status";
// Use only RFC 7807 plus body.message:
const expectStatus = createExpectStatus({
extractMessage: chainExtractors(problemDetail, messageField),
});
// Or write your own primitive and slot it in:
const myCodeField = (body: unknown) =>
typeof body === "object" && body !== null && "reason" in body
? String(body.reason)
: undefined;
const expectStatus = createExpectStatus({
extractMessage: chainExtractors(myCodeField, messageField, problemDetail),
});chainExtractors returns the first non-undefined result from its inputs. Order matters — the leftmost extractor wins.
Dispatch keys and the expected-status argument accept class-level ranges and named specifiers:
| Specifier | Matches |
|---|---|
'1xx' |
100–199 (informational) |
'2xx' |
200–299 (success) |
'3xx' |
300–399 (redirection) |
'4xx' |
400–499 (client errors) |
'5xx' |
500–599 (server errors) |
'success' |
200–299 (alias for '2xx') |
'error' |
400–599 (client + server) |
'!4xx' |
Anything except 400–499 |
'!success' |
Anything except 200–299 |
// As expected status
const body = await expectStatus("success", response);
const data = await expectStatus("!4xx", response);
// In dispatch
await expectStatus(200, response, {
"4xx": "Client error",
"5xx": (body) => Sentry.captureMessage(JSON.stringify(body)),
});Tens-level granularity ('40x', '42x', etc.) is intentionally not supported — real APIs differentiate 422 (validation) from 429 (rate-limit) from 451 (legal), so bundling them under a tens-range usually hides design intent. Use exact codes for those.
Add exhaustive: true to require every error status be covered:
await expectStatus(201, res, {
409: "Conflict",
422: "Invalid input",
exhaustive: true,
});A runtime guard fires if exhaustive: true is set but a status is uncovered at runtime — surfacing the gap loudly rather than silently degrading to extractMessage / fallbackMessage.
// recover — catch-all that wraps the entire error path:
const result = await expectStatus(201, res, {
recover: (err) => ({ fallback: true, reason: err.message }),
});
// transform — reshape the success body before returning:
const wrapped = await expectStatus(200, res, {
transform: (body) => ({ data: body, timestamp: Date.now() }),
});recover catches handler throws, message throws, and fallback throws — it's a true catch-all. If it returns undefined, the error is re-thrown. onError fires before recover.
transform runs after onSuccess on the success path.
Returns a typed SafeResult<T> instead of throwing:
const result = await expectStatus(200, res, { throws: false });
if (result.ok) {
result.data; // typed body
} else {
result.error; // Error
result.status; // number
result.body; // unknown
}Define domain-specific status groups on the instance:
const expectStatus = createExpectStatus({
groups: {
auth: [401, 403],
retryable: [408, 429, 503],
},
});
// As expected status
await expectStatus("auth", res);
// In dispatch
await expectStatus(200, res, {
auth: "Please sign in.",
retryable: (body) => retryQueue.add(body),
});Normalize non-standard response shapes at the instance level. Use a built-in preset or write your own:
import { createExpectStatus, adapters } from "expect-status";
const expectStatus = createExpectStatus({ adapter: adapters.axios });See Adapter presets for the full list. Custom adapters are just functions:
const expectStatus = createExpectStatus({
adapter: (res) => ({ status: res.meta.httpStatus, body: res.result.data }),
});The adapter runs first, before any dispatch logic. If no adapter is provided, the library reads status/body directly (standard behaviour).
Default error thrown by the default instance. Carries status and body for catch-block inspection.
try {
await expectStatus(200, res);
} catch (err) {
if (err instanceof ExpectStatusError) {
console.log(err.status, err.body, err.message);
}
}import type {
SafeResult,
StatusResponse,
StatusRange,
StatusGroup,
StatusSpecifier,
StatusArg,
SuccessArg,
AsStatuses,
ResolveSuccessBody,
ResolveErrorStatus,
ExpectStatusFn,
ExpectStatusOptions,
ExhaustiveCheck,
IsCovered,
StatusToClass,
UncoveredErrors,
StatusOf,
BodyOf,
ExtractBranch,
Extractor,
} from "expect-status";
type CreateOrgResponse =
| { status: 201; body: Organisation }
| { status: 409; body: { message: string } };
type Organisation = ResolveSuccessBody<CreateOrgResponse, 201>;
type EitherBody = ResolveSuccessBody<CreateOrgResponse, readonly [201, 409]>;
// ^? Organisation | { message: string }import {
rangeOf, // (status: number) => StatusRange | undefined — e.g. rangeOf(404) → '4xx'
isStatusRange, // (value: unknown) => value is StatusRange
isStatusGroup, // (value: unknown) => value is StatusGroup
isStatusSpecifier, // (value: unknown) => value is StatusSpecifier
matchesSpecifier, // (status: number, spec: StatusSpecifier) => boolean
parseStatusArg, // (arg: StatusArg) => number[] — expands ranges
matchesStatusArg, // (status: number, arg: StatusArg) => boolean
} from "expect-status";expect-status pairs naturally with TanStack Query — the thrown error becomes the query's error state:
import { useQuery, useMutation } from "@tanstack/react-query";
import { expectStatus } from "expect-status";
function useOrganisation(id: string) {
return useQuery({
queryKey: ["org", id],
queryFn: () =>
expectStatus(200, client.getOrganisation({ params: { id } })),
// ^? () => Promise<Organisation>
});
}
function useCreateOrganisation() {
return useMutation({
mutationFn: (data: CreateOrgInput) =>
expectStatus(201, client.createOrganisation({ body: data }), {
409: (body) => redirect(`/org/${body.organisationId}`),
422: "Invalid organisation details.",
}),
});
}expectStatus returns a promise that resolves to the typed body or throws, which is exactly what TanStack Query expects.
For mutations where you want structured results instead of exceptions:
function useCreateOrganisation() {
return useMutation({
mutationFn: async (data: CreateOrgInput) => {
const result = await expectStatus(
201,
client.createOrganisation({ body: data }),
{ throws: false },
);
if (!result.ok) {
return { error: result.error.message };
}
return { data: result.data };
},
});
}Use the adapter to avoid manually wrapping Axios responses:
import axios from "axios";
import { createExpectStatus } from "expect-status";
const api = axios.create({
baseURL: "https://api.example.com",
validateStatus: () => true, // don't throw on non-2xx
});
const expectStatus = createExpectStatus({
adapter: (res) => ({ status: res.status, body: res.data }),
fallbackMessage: "Request failed.",
defaults: {
401: "Please sign in.",
"5xx": "Service unavailable.",
},
});
// No need to manually wrap — the adapter handles { status, data } → { status, body }
const org = await expectStatus(201, api.post("/orgs", data));async function onSubmit(formData: FormData) {
try {
const org = await expectStatus(
201,
client.createOrganisation({ body: formData }),
{
409: "An organisation with that name already exists.",
422: "Please check the form and try again.",
},
);
redirect(`/org/${org.id}`);
} catch (err) {
// err.message is the per-status message or the extracted backend message
toast.error(err.message);
}
}async function onSubmit(formData: FormData) {
const result = await expectStatus(
201,
client.createOrganisation({ body: formData }),
{
409: "An organisation with that name already exists.",
422: "Please check the form and try again.",
recover: (err) => ({ error: err.message }),
},
);
if ("error" in result) {
toast.error(result.error);
} else {
redirect(`/org/${result.id}`);
}
}Errors thrown by expectStatus propagate naturally to React error boundaries:
// In a Server Component or loader
async function loadOrganisation(id: string) {
return expectStatus(200, client.getOrganisation({ params: { id } }), {
404: "Organisation not found.",
});
}
// Non-success statuses throw → caught by the nearest ErrorBoundary"use server";
import { expectStatus } from "expect-status";
import { redirect } from "next/navigation";
export async function createOrganisation(formData: FormData) {
const org = await expectStatus(
201,
client.createOrganisation({ body: { name: formData.get("name") } }),
{
409: "An organisation with that name already exists.",
422: "Please check the form and try again.",
},
);
redirect(`/org/${org.id}`);
}| Client | Setup | Preset |
|---|---|---|
| ts-rest | None — returns { status, body } natively |
— |
| Orval | adapter: adapters.axios |
adapters.axios |
| openapi-fetch | adapter: adapters.openapiClient |
adapters.openapiClient |
| hey-api | adapter: adapters.openapiClient |
adapters.openapiClient |
| Axios | adapter: adapters.axios |
adapters.axios |
| Native fetch | adapter: adapters.fetch or fetchExpect |
adapters.fetch |
| Hand-rolled | None — if you return { status, body } |
— |
const org = await expectStatus(201, client.createOrganisation({ body: data }));import { createExpectStatus, adapters } from "expect-status";
const expectStatus = createExpectStatus({ adapter: adapters.axios });
const org = await expectStatus(201, createOrganisation(data));import { createExpectStatus, adapters } from "expect-status";
const expectStatus = createExpectStatus({ adapter: adapters.openapiClient });
const user = await expectStatus(
200,
client.GET("/users/{id}", { params: { path: { id } } }),
);import { createExpectStatus, adapters } from "expect-status";
const expectStatus = createExpectStatus({ adapter: adapters.openapiClient });
const org = await expectStatus(200, getOrganisation({ path: { id } }));type FooResponse =
| { status: 200; body: Foo }
| { status: 404; body: { message: string } };
async function getFoo(id: string): Promise<FooResponse> {
const r = await fetch(`/foo/${id}`);
return { status: r.status, body: await r.json() } as FooResponse;
}
const foo = await expectStatus(200, getFoo("123"));ts-pattern is the idiomatic library for general pattern matching in TypeScript. You can use it for status dispatch:
import { match } from "ts-pattern";
const result = match(res)
.with({ status: 201 }, (r) => r.body)
.with({ status: 409 }, (r) => {
redirect(`/org/${r.body.organisationId}`);
})
.with({ status: 410 }, () => {
throw new Error("Expired");
})
.otherwise((r) => {
throw new Error(r.body.message ?? "Failed");
});expect-status is more terse for the common case (flat dispatch vs .with() chain) and bakes in:
- Backend message bubbling — unhandled statuses fall through to
extractMessage(body)automatically. - Class-range catch-alls —
'4xx','5xx','success','error'keys. - Named specifiers and negation —
'!4xx','!success'as expected status. - Instance-wide defaults — set once, reuse everywhere.
recovercatch-all — return instead of throw, wraps the entire error path.throws: false— structuredSafeResult<T>without try/catch.- Custom groups — domain-specific status sets like
auth,retryable. - Observability hooks — central error/success logging at the dispatch layer.
ts-pattern gives you .exhaustive() for compile-time exhaustiveness checking; expect-status matches that with exhaustive: true. Pick the one that fits how much you care about call-site terseness vs general pattern matching.
A few things this library deliberately doesn't do:
- Non-numeric discriminators (string tags like
{ tag: 'success' }, GraphQL__typename, etc.) — these are general tagged-union pattern matching, whichts-patternalready handles cleanly.expect-statusstays anchored on numeric HTTP-style status codes so it can offer class-range matchers ('4xx','5xx') and the HTTP-aware exhaustive check. - Tens-level ranges (
'40x','42x') — real APIs differentiate 422 / 429 / 451, so a tens-range usually hides design intent. Use exact codes or custom groups. - Schema validation, retries, sync variants — different concerns; keep them at the layer where they belong (your codegen, your transport, your runtime). A thin
fetchExpecthelper is provided atexpect-status/fetchfor nativefetchintegration — see Native fetch integration.
| v1 | v2 |
|---|---|
expectStatus(response, 200) |
expectStatus(200, response) |
{ handlers: { 409: fn }, messages: { 422: "msg" } } |
{ 409: fn, 422: "msg" } |
handleError: (err) => fallback |
recover: (err) => fallback |
handleSuccess: (body) => transformed |
transform: (body) => transformed |
defaults: { messages: { 401: "Sign in" } } |
defaults: { 401: "Sign in" } |
MIT © zak-js