TypeScript-first · Zero runtime dependencies

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.

$npm install expect-status
Before
3 unsafe casts · missing statuses · fragile fallback
without.ts
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)
}
After
Array + ranges · inferred bodies · auto-throw
with-expect-status.ts
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
}
The problem

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 Organisation

TypeScript 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.

Examples

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.

dispatch.ts
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)
Features

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.

Scope

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-pattern
  • Tens-level ranges like '40x' or '42x'

    Real APIs differentiate 422 (validation), 429 (rate limit), and 451 (legal). Bundling them hides design intent.

    Use exact codes
  • Schema validation on the response body

    Validation is a separate concern. expect-status trusts the types your codegen provides.

    Use zod, valibot, or your codegen
  • Retries, request interceptors, or sync variants

    Different layers, different concerns — keep them where they belong.

    Use your transport / runtime
Resolution order

How a non-success status resolves.

Handlers (functions) are checked before messages (strings). Within each tier, exact codes shadow ranges, which shadow groups.

  1. 1
    per-call handlerMost specific function match (exact code → range → group).
  2. 2
    instance handlerMost specific function match from defaults.
  3. 3
    per-call messageMost specific string match (exact code → range → group).
  4. 4
    instance messageMost specific string match from defaults.
  5. 5
    extractMessage(body)Pulls a message from the response body.
  6. 6
    fallbackMessageLast-resort static string.
  7. 7
    onErrorObservability hook — fires once before throwing.
  8. 8
    recover(error)True catch-all; returns a value instead of throwing.
Works with

Drop into any TypeScript API client.

ts-restnative — zero config
orvaladapters.axios
openapi-fetchadapters.openapiClient
hey-apiadapters.openapiClient
Axiosadapters.axios
native fetchadapters.fetch

Stop writing dispatch boilerplate.

Install expect-status, pass the response, and let every error path handle itself.

$npm install expect-status
View on GitHubOpen on npm
expect-status201

MIT © zak-js · TypeScript 5.4+ · Node 18+