Type-safe status dispatch for any API client.
Turns status-discriminated API responses into ergonomic, type-narrowed control flow. Zero runtime dependencies.
Works with ts-rest, Axios, orval, openapi-fetch, hey-api, and anything that speaks HTTP.
async function createOrg(data: CreateOrgInput) { const res = await api.createOrg(data) if (res.status === 201) { return res.body as Organisation } if (res.status === 409) { const body = res.body as { orgId: string } return redirect(`/org/${body.orgId}`) } if (res.status === 422) { const body = res.body as { errors: string[] } throw new Error(body.errors.join(', ')) } // 401? 403? 429? 500? Hopefully we didn't miss one... const msg = (res.body as any)?.message ?? 'Something went wrong' throw new Error(msg)}async function createOrg(data: CreateOrgInput) { const org = await expectStatus( 201, // expected status api.createOrg(data), { 409: ({ orgId }) => redirect(`/org/${orgId}`), // body typed from 409 branch 422: 'Please check your input.', '5xx': 'Service unavailable.', // catch entire class }, ) return org // → typed as Organisation — zero casts // → any other error auto-throws with body.message}Your types are precise. Your call sites aren't.
Typed API clients give you discriminated response unions — but every consumer still writes the same manual branching, casting, and fallback logic.
Duplicated everywhere
if (res.status === 409) { ... }The same if/else chain copy-pasted at every call site. Dozens of branches, all slightly different.
Unsafe casts
res.body as OrganisationTypeScript can't narrow through manual branches — you reach for as and lose the safety your codegen gave you.
Silent gaps
// 401? 429? 500? 🤷Miss a status and nothing warns you. The error falls through to a generic catch or, worse, is swallowed entirely.
expect-status eliminates all three. One call, typed bodies, every status covered.
First-class type narrowing. Configurable error paths.
Use the simple one-line form for happy paths, then add exact handlers, class-range catch-alls, app-wide defaults, or exhaustive checking when needed.
Functions are handlers (receive the inferred body), strings are messages (auto-throw). Mix them freely in one object.
const org = await expectStatus(201, api.createOrg({ body: data }), { 409: ({ orgId }) => redirect(`/org/${orgId}`), // handler — body inferred 422: 'Please check your input.', // message — auto-throws '5xx': 'Service is temporarily unavailable.', // range — catches entire class})// → org: Organisation (typed return, zero casts)Precise types deserve precise handling.
Keep call sites small without giving up exact error handling, app-wide defaults, useful backend messages, or compile-time coverage.
One call replaces your if/else chain
Functions are handlers, strings are messages — { 409: fn, 422: "msg", '5xx': "fallback" } in one flat object. Zero nesting.
Ranges, negation & custom groups
Use '4xx', 'success', '!error', or define named groups like { auth: [401, 403] }. Exact codes still take priority.
Shared defaults with createExpectStatus
Configure fallback messages, error logging (onError), success tracking (onSuccess), and default handlers once. Every call inherits them.
Recover, transform & throws: false
recover catches any error. transform reshapes the success body. throws: false returns a typed SafeResult instead of throwing.
Make missing error handling visible
Turn on exhaustive: true and TypeScript flags any uncovered error status by name in the type error.
Bubble useful backend messages
Extract messages from common shapes — body.message, RFC 7807 problem details, array errors, Spring-style — or chain your own.
Built-in adapter presets
adapters.axios, adapters.openapiClient, adapters.fetch — import the preset, plug it in, done. Or write your own for custom envelopes.
Native fetch wrapper included
fetchExpect wraps fetch, parses JSON, validates the status, and returns the typed body. Same flat dispatch, no client needed.
No transport, schema, or runtime deps
Works after your client returns. Does not replace fetch, validate schemas, or bring a framework. Zero runtime dependencies.
Non-goals.
expect-status is a post-response validator, not a transport layer. These are deliberate boundaries.
Non-numeric discriminators
String tags, GraphQL __typename, or other general tagged-union shapes.
Use ts-patternTens-level ranges like '40x' or '42x'
Real APIs differentiate 422 (validation), 429 (rate limit), and 451 (legal). Bundling them hides design intent.
Use exact codesSchema validation on the response body
Validation is a separate concern. expect-status trusts the types your codegen provides.
Use zod, valibot, or your codegenRetries, request interceptors, or sync variants
Different layers, different concerns — keep them where they belong.
Use your transport / runtime
How a non-success status resolves.
Handlers (functions) are checked before messages (strings). Within each tier, exact codes shadow ranges, which shadow groups.
- 1
per-call handlerMost specific function match (exact code → range → group). - 2
instance handlerMost specific function match from defaults. - 3
per-call messageMost specific string match (exact code → range → group). - 4
instance messageMost specific string match from defaults. - 5
extractMessage(body)Pulls a message from the response body. - 6
fallbackMessageLast-resort static string. - 7
onErrorObservability hook — fires once before throwing. - 8
recover(error)True catch-all; returns a value instead of throwing.
Drop into any TypeScript API client.
Stop writing dispatch
boilerplate.
Install expect-status, pass the response, and let every error path handle itself.