[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"project-78197":3},{"id":4,"name":5,"fullName":6,"owner":7,"repo":5,"description":8,"homepage":9,"htmlUrl":10,"language":11,"languages":10,"totalLinesOfCode":10,"stars":12,"forks":13,"watchers":14,"openIssues":13,"contributorsCount":13,"subscribersCount":13,"size":13,"stars1d":13,"stars7d":13,"stars30d":15,"stars90d":13,"forks30d":13,"starsTrendScore":13,"compositeScore":16,"rankGlobal":10,"rankLanguage":10,"license":17,"archived":18,"fork":18,"defaultBranch":19,"hasWiki":20,"hasPages":20,"topics":21,"createdAt":10,"pushedAt":10,"updatedAt":38,"readmeContent":39,"aiSummary":40,"trendingCount":13,"starSnapshotCount":13,"syncStatus":41,"lastSyncTime":42,"discoverSource":43},78197,"expect-status","Zak-JS\u002Fexpect-status","Zak-JS","Type-safe API status handling for TypeScript: one consistent pattern with full inference, sensible fallbacks, reusable instances, and client adapters.","https:\u002F\u002Fzak-js.github.io\u002Fexpect-status",null,"TypeScript",30,0,116,3,40.3,"MIT License",false,"main",true,[22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37],"api-client","axios","developer-tools","discriminated-unions","error-handling","fetch","hey-api","openapi","openapi-fetch","orval","status-codes","tanstack-query","ts-pattern","ts-rest","type-safety","typescript","2026-06-12 04:01:23","# expect-status\n\nType-safe API status handling for TypeScript: one consistent pattern with full inference, sensible fallbacks, reusable instances, and client adapters.\n\n[![npm version](https:\u002F\u002Fimg.shields.io\u002Fnpm\u002Fv\u002Fexpect-status)](https:\u002F\u002Fwww.npmjs.com\u002Fpackage\u002Fexpect-status)\n[![npm downloads](https:\u002F\u002Fimg.shields.io\u002Fnpm\u002Fdw\u002Fexpect-status)](https:\u002F\u002Fwww.npmjs.com\u002Fpackage\u002Fexpect-status)\n[![CI](https:\u002F\u002Fgithub.com\u002FZak-JS\u002Fexpect-status\u002Factions\u002Fworkflows\u002Fci.yml\u002Fbadge.svg)](https:\u002F\u002Fgithub.com\u002FZak-JS\u002Fexpect-status\u002Factions\u002Fworkflows\u002Fci.yml)\n\n[**Documentation**](https:\u002F\u002Fzak-js.github.io\u002Fexpect-status) · [Quick Start](https:\u002F\u002Fzak-js.github.io\u002Fexpect-status\u002Fquick-start) · [API Reference](https:\u002F\u002Fzak-js.github.io\u002Fexpect-status\u002Fapi\u002Foverview)\n\n## Before \u002F after\n\n```ts\n\u002F\u002F ❌ Without expect-status\n\nconst res = await client.POST(\"\u002Forganisations\", { body: data });\n\nif (res.response.status === 201) return res.data!; \u002F\u002F unsafe assertion\nif (res.response.status === 409) {\n  return router.push(`\u002Forg\u002F${(res.error as any).organisationId}`); \u002F\u002F manual cast\n}\nif (res.response.status === 422) {\n  setFieldErrors((res.error as any).fieldErrors); \u002F\u002F manual cast\n  throw new Error((res.error as any).message); \u002F\u002F manual cast\n}\nif (res.response.status === 401) return router.push(\"\u002Flogin\"); \u002F\u002F where do shared defaults live?\nif (res.response.status === 403) throw new Error(\"No permission.\"); \u002F\u002F how do you override this per-call?\nif (res.response.status >= 500) {\n  Sentry.captureException(new Error(\"API error\")); \u002F\u002F no central hook\n  throw new Error(\"Service unavailable.\");\n}\nthrow new Error((res.error as any)?.message ?? \"Unknown error\"); \u002F\u002F pray the shape is right\n```\n\n```ts\n\u002F\u002F ✅ With expect-status\n\nconst org = await expectStatus(\n  201,\n  client.POST(\"\u002Forganisations\", { body: data }),\n  {\n    409: ({ organisationId }) => router.push(`\u002Forg\u002F${organisationId}`),\n    422: \"Please fix the highlighted fields.\",\n  },\n);\n```\n\nOne line per concern. `org` is typed as `Organisation`. Strings throw with that message, functions run logic. Everything else — 401 redirects, 5xx fallback, Sentry — lives in [instance defaults](#setup-once).\n\n### Setup (once)\n\n```ts\n\u002F\u002F lib\u002Fexpect-status.ts — define once, import everywhere\n\nimport { createExpectStatus, adapters } from \"expect-status\";\n\nexport const expectStatus = createExpectStatus({\n  adapter: adapters.openapiClient, \u002F\u002F normalize openapi-fetch \u002F hey-api responses\n\n  \u002F\u002F Sensible fallbacks — handle common statuses app-wide so call sites don't have to\n  defaults: {\n    401: () => router.push(\"\u002Flogin\"), \u002F\u002F function → handler (runs logic, can return\u002Fthrow)\n    403: \"You do not have permission.\", \u002F\u002F string → auto-throws with this message\n    429: \"Too many requests. Try again later.\",\n    \"5xx\": \"Service unavailable.\", \u002F\u002F status range — catches 500, 502, 503, 504, etc.\n    \"!success\": \"Something went wrong.\", \u002F\u002F negation — fallback for anything outside 2xx\n  },\n\n  \u002F\u002F Custom groups — domain-specific status sets you can use as specifiers\n  groups: { auth: [401, 403] }, \u002F\u002F now you can do: expectStatus('auth', ...)\n\n  \u002F\u002F Observability — fires on every error\u002Fsuccess across the app\n  onError: (err, res) =>\n    Sentry.captureException(err, { tags: { status: String(res.status) } }),\n  onSuccess: (res) => analytics.track(\"api_success\", { status: res.status }),\n});\n```\n\n### Usage\n\n```ts\n\u002F\u002F Simple — just expect a status, everything else falls through to defaults\nconst org = await expectStatus(\n  201,\n  client.POST(\"\u002Forganisations\", { body: data }),\n);\n\u002F\u002F ^ org: Organisation — typed, no cast\n\u002F\u002F ^ 401 → redirects, 403 → throws, 5xx → throws — all from defaults\n\n\u002F\u002F Per-call handlers — shadow defaults when this endpoint needs special behavior\nconst org = await expectStatus(\n  201,\n  client.POST(\"\u002Forganisations\", { body: data }),\n  {\n    409: ({ organisationId }) => router.push(`\u002Forg\u002F${organisationId}`),\n    422: ({ fieldErrors }) => {\n      setFieldErrors(fieldErrors);\n      throw new Error(\"Fix fields.\");\n    },\n    401: \"Session expired. Please sign in again.\", \u002F\u002F ← shadows the instance's 401 default\n  },\n);\n\n\u002F\u002F Multiple success codes\nconst member = await expectStatus(\n  [200, 201],\n  client.POST(\"\u002Fmembers\", { body: invite }),\n);\n\u002F\u002F ^ typed as body of 200 | body of 201\n\n\u002F\u002F Status specifiers\nawait expectStatus(\"success\", client.GET(\"\u002Fhealth\")); \u002F\u002F any 2xx\nawait expectStatus(\"!error\", client.GET(\"\u002Fprobe\")); \u002F\u002F anything except 4xx\u002F5xx\nawait expectStatus(\"auth\", client.GET(\"\u002Fme\")); \u002F\u002F custom group: [401, 403]\nawait expectStatus([200, \"3xx\"], client.GET(\"\u002Ffollow\")); \u002F\u002F mixed array\n\n\u002F\u002F Graceful fallback — never throws\nconst flags = await expectStatus(200, client.GET(\"\u002Ffeature-flags\"), {\n  recover: () => DEFAULT_FLAGS,\n});\n\n\u002F\u002F Reshape success body\nconst user = await expectStatus(\n  200,\n  client.GET(\"\u002Fusers\u002F{id}\", { params: { path: { id } } }),\n  {\n    transform: (body) => ({ ...body, fetchedAt: Date.now() }),\n  },\n);\n\n\u002F\u002F Non-throwing mode — typed SafeResult\nconst result = await expectStatus(200, client.GET(\"\u002Fconfig\"), {\n  throws: false,\n});\nif (result.ok)\n  console.log(result.data); \u002F\u002F typed success body\nelse console.error(result.error); \u002F\u002F ExpectStatusError\n```\n\n## Why teams use it\n\n- **Consistent status handling** across the app — not per-call custom branching\n- **Full type inference** for success and error bodies by status\n- **Sensible fallbacks** via messages, ranges (`'4xx'`, `'5xx'`), and `recover`\n- **Reusable instances** with shared defaults, hooks, and custom groups\n- **Adapter presets** for Axios, Orval, openapi-fetch, hey-api, and native fetch\n\n## Install\n\n```sh\nnpm install expect-status\n# or\npnpm add expect-status\n# or\nyarn add expect-status\n```\n\nRequires TypeScript 5.4+ for `infer T extends U` and template literal status-class inference. Node 18+.\n\n## Best results with status-discriminated responses\n\n`expect-status` works with any API client, but you get **full per-status type inference** when your client returns status-discriminated responses — where each status maps to a typed body:\n\n```ts\ntype Response =\n  | { status: 201; body: Organisation }\n  | { status: 409; body: { organisationId: string } }\n  | { status: 422; body: { fieldErrors: Record\u003Cstring, string> } };\n```\n\n### Which tools give you this?\n\n| Tool                                 | Full per-status inference? | Notes                                           |\n| ------------------------------------ | -------------------------- | ----------------------------------------------- |\n| **ts-rest**                          | ✅ Yes                     | Returns `{ status, body }` unions natively      |\n| **openapi-typescript + typed fetch** | ✅ Yes                     | You control the response shape                  |\n| **openapi-fetch**                    | Success body only          | Error bodies aren't discriminated — use adapter |\n| **hey-api**                          | Success body only          | Same as above                                   |\n| **Axios \u002F Orval**                    | Success body only          | Use `adapters.axios`                            |\n\nFor full type inference on every branch (success AND error handlers), we recommend a codegen that emits status-discriminated unions:\n\n- **ts-rest** — if you control both client and server\n- **openapi-typescript** + a thin typed fetch wrapper — if you have an OpenAPI spec\n\n### Without a codegen\n\nDon't use a codegen? You can still type your handlers. Define common error body types on the instance, and use the `typed()` helper for per-call overrides:\n\n```ts\nimport { createExpectStatus, typed } from \"expect-status\";\n\n\u002F\u002F Instance-level error map — typed once, applies to all calls\nconst expectStatus = createExpectStatus\u003C{\n  401: { message: string };\n  403: { message: string };\n  422: { fieldErrors: Record\u003Cstring, string>; message: string };\n}>({\n  \u002F\u002F Custom adapter — normalize any response shape to { status, body }\n  adapter: (res) => ({\n    status: res.meta.code,\n    body: res.result,\n  }),\n\n  defaults: {\n    401: ({ message }) => router.push(\"\u002Flogin\"), \u002F\u002F ← typed from instance error map\n    403: \"No permission.\",\n    422: ({ fieldErrors }) => setFieldErrors(fieldErrors), \u002F\u002F ← typed from instance error map\n    \"5xx\": \"Service unavailable.\",\n  },\n});\n\n\u002F\u002F Success body inferred from client — error handlers typed from instance map\nconst org = await expectStatus(201, api.createOrg(data));\n\n\u002F\u002F Per-call error types — use typed() for inline inference\nconst org = await expectStatus(\n  201,\n  api.createOrg(data),\n  typed\u003C{\n    409: { organisationId: string };\n  }>({\n    409: ({ organisationId }) => router.push(`\u002Forg\u002F${organisationId}`), \u002F\u002F ← typed\n  }),\n);\n```\n\nOr define your response types manually with the `StatusMap` helper:\n\n```ts\nimport { expectStatus, type StatusMap } from \"expect-status\";\n\ntype CreateOrgResponse = StatusMap\u003C{\n  201: Organisation;\n  409: { organisationId: string };\n  422: { fieldErrors: Record\u003Cstring, string> };\n}>;\n\u002F\u002F ^ expands to { status: 201; body: Organisation } | { status: 409; ... } | ...\n\nconst res = (await createOrg(data)) as CreateOrgResponse;\nconst org = await expectStatus(201, res);\n\u002F\u002F ^ full per-status inference, no codegen\n```\n\n### Still useful without any type annotations\n\nEven without per-status types, `expect-status` gives you centralized defaults, observability hooks, status ranges, message bubbling, recover\u002Ftransform, and a consistent dispatch pattern across your app. Handler bodies will be `unknown` — you annotate inline only where you need to destructure.\n\n## Single API layer (optional)\n\nIf you want the instance to make HTTP requests directly, pass a `fetcher` function. The instance gains `.get()`, `.post()`, `.put()`, `.patch()`, `.delete()` methods that call your fetcher, normalize the response, and dispatch through all your defaults and hooks.\n\n**This is not required.** If you already have a typed client (ts-rest, openapi-fetch, Axios), just use `expectStatus(status, promise)` directly.\n\n```ts\nimport { createExpectStatus } from \"expect-status\";\n\nconst api = createExpectStatus\u003C{\n  401: { message: string };\n  403: { message: string };\n  409: { organisationId: string };\n  422: { fieldErrors: Record\u003Cstring, string>; message: string };\n}>({\n  fetcher: (url, init) =>\n    fetch(`https:\u002F\u002Fapi.example.com${url}`, {\n      ...init,\n      headers: { ...init?.headers, Authorization: `Bearer ${getToken()}` },\n    }),\n  defaults: {\n    401: ({ message }) => router.push(\"\u002Flogin\"),\n    403: \"You do not have permission.\",\n    \"5xx\": \"Service unavailable.\",\n  },\n  onError: (err, res) => Sentry.captureException(err),\n});\n\n\u002F\u002F GET\nconst user = await api.get\u003CUser>(\"\u002Fusers\u002Fme\", 200);\n\n\u002F\u002F POST — body is typed via second generic\nconst org = await api.post\u003COrganisation, CreateOrgInput>(\n  \"\u002Forganisations\",\n  data,\n  201,\n  {\n    409: ({ organisationId }) => router.push(`\u002Forg\u002F${organisationId}`), \u002F\u002F ← typed from instance map\n    422: ({ fieldErrors }) => setFieldErrors(fieldErrors), \u002F\u002F ← typed from instance map\n  },\n);\n\n\u002F\u002F PUT\nconst updated = await api.put\u003COrganisation, UpdateOrgInput>(\n  \"\u002Forganisations\u002F123\",\n  changes,\n  200,\n);\n\n\u002F\u002F DELETE\nawait api.delete(\"\u002Forganisations\u002F123\", 204);\n\n\u002F\u002F Override fetcher options per-call (headers, signal, etc.)\nconst flags = await api.get\u003CFeatureFlags>(\"\u002Ffeature-flags\", 200, {\n  headers: { \"X-Region\": \"eu-west-1\" },\n  signal: abortController.signal,\n  recover: () => DEFAULT_FLAGS,\n});\n```\n\n```ts\n\u002F\u002F Axios — custom adapter normalizes the response\nconst api = createExpectStatus({\n  fetcher: (url, init) =>\n    axios({\n      url,\n      method: init?.method,\n      data: init?.body,\n      headers: init?.headers,\n    }),\n  adapter: (res) => ({ status: res.status, body: res.data }),\n  defaults: { 401: () => router.push(\"\u002Flogin\"), \"5xx\": \"Service unavailable.\" },\n});\n```\n\nMethod helpers auto-set `Content-Type: application\u002Fjson` for object bodies. Per-call options (headers, signal, etc.) merge with the fetcher's defaults. Dispatch keys (status codes, ranges, `recover`, etc.) are separated automatically.\n\n## Adapter presets\n\n`expect-status` ships built-in presets for common clients. Import, plug in, done:\n\n```ts\nimport { createExpectStatus, adapters } from \"expect-status\";\n\n\u002F\u002F Axios \u002F Orval\nconst expectStatus = createExpectStatus({ adapter: adapters.axios });\n\n\u002F\u002F openapi-fetch \u002F hey-api\nconst expectStatus = createExpectStatus({ adapter: adapters.openapiClient });\n\n\u002F\u002F Native fetch\nconst expectStatus = createExpectStatus({ adapter: adapters.fetch });\n```\n\n| Preset                   | Maps                                             | For                    |\n| ------------------------ | ------------------------------------------------ | ---------------------- |\n| `adapters.axios`         | `{ status, data }` → `{ status, body }`          | Axios, Orval           |\n| `adapters.openapiClient` | `{ data, error, response }` → `{ status, body }` | openapi-fetch, hey-api |\n| `adapters.fetch`         | `Response` → `{ status, await json() }`          | Native fetch (async)   |\n\nOr write your own for custom envelopes:\n\n```ts\nconst expectStatus = createExpectStatus({\n  adapter: (res) => ({ status: res.meta.httpStatus, body: res.result.data }),\n});\n```\n\nThe 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.\n\n## How it resolves a non-success status\n\nHandlers (functions) are always checked before messages (strings), even across per-call and instance defaults:\n\n1. **Per-call handler** — most specific match (exact code → range → group).\n2. **Instance default handler** — most specific match.\n3. **Per-call message** — most specific match.\n4. **Instance default message** — most specific match.\n5. `extractMessage(body)` — pulls a message from the response body.\n6. `fallbackMessage` — last-resort static string.\n7. `onError` fires once with the resolved error just before it's thrown.\n8. `recover` — true catch-all; if it returns a non-`undefined` value, that value is the result instead of throwing.\n\nWithin each tier, exact codes shadow ranges, which shadow custom groups.\n\nOn success: `onSuccess` fires → `transform` reshapes body → return.\n\n## API\n\n### `expectStatus(successStatus, response, dispatch?)`\n\nThe default instance. Throws `ExpectStatusError` on non-success statuses with no handler, bubbling messages from common error-body shapes.\n\n`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.\n\n### `createExpectStatus(options?)`\n\nBuild a configured instance with your own error class, message extractor, fallback message, instance defaults, custom groups, adapter, and observability hooks.\n\n```ts\nimport { createExpectStatus } from \"expect-status\";\n\nclass RequestError extends Error {}\n\nexport const expectStatus = createExpectStatus({\n  errorFactory: (message) => new RequestError(message),\n  fallbackMessage: \"Something went wrong. Please try again.\",\n  groups: {\n    auth: [401, 403],\n    retryable: [408, 429, 503],\n  },\n  defaults: {\n    auth: \"Please sign in or check your permissions.\",\n    \"5xx\": \"Service is temporarily unavailable. Please retry shortly.\",\n  },\n  onError: (err, response) =>\n    Sentry.captureException(err, {\n      extra: { status: response.status, body: response.body },\n    }),\n});\n```\n\n#### Options\n\n| Option            | Type                                                | Default                                       | Description                                                                                                       |\n| ----------------- | --------------------------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |\n| `statusField`     | `string` literal                                    | `'status'`                                    | Field on the response holding the numeric status. Override for envelope schemas like `{ code: 200; payload: T }`. |\n| `bodyField`       | `string` literal                                    | `'body'`                                      | Field on the response holding the body payload.                                                                   |\n| `errorFactory`    | `(message, response) => Error`                      | `new ExpectStatusError(...)`                  | Constructs the error thrown on non-success statuses.                                                              |\n| `extractMessage`  | `(body: unknown) => string \\| undefined`            | `defaultExtractMessage`                       | Pulls a user-facing message from the body. See **Composable extractors** below.                                   |\n| `fallbackMessage` | `string`                                            | `'Request failed with an unexpected status.'` | Used when no other source supplies a message.                                                                     |\n| `groups`          | `Record\u003Cstring, number[]>`                          | `{}`                                          | Custom named status groups. E.g. `{ auth: [401, 403] }`. Usable as expected status or dispatch keys.              |\n| `adapter`         | `(response: T) => { status: number; body: unknown}` | none                                          | Normalizes non-standard response shapes (e.g. Axios `{ data }`) before dispatch. Runs first.                      |\n| `defaults`        | `Record\u003Cstring \\| number, string \\| Function>`      | `{}`                                          | Instance-wide default flat dispatch entries. Per-call dispatch shadows these.                                     |\n| `onError`         | `(error: Error, response) => void \\| Promise\u003Cvoid>` | none                                          | Observability hook fired once per dispatched error. Errors inside the hook are swallowed.                         |\n| `onSuccess`       | `(response) => void \\| Promise\u003Cvoid>`               | none                                          | Observability hook fired once when the status matches the success criteria. Errors inside the hook are swallowed. |\n\n### Custom field names\n\nFor envelope schemas with non-canonical field names, override `statusField` and `bodyField`:\n\n```ts\ntype CodeResponse =\n  | { code: 200; payload: { id: string } }\n  | { code: 409; payload: { msg: string; orgId: string } };\n\nconst expectCode = createExpectStatus({\n  statusField: \"code\",\n  bodyField: \"payload\",\n});\n\nconst result = await expectCode(200, res);\n\u002F\u002F    ^? { id: string }\n\nawait expectCode(200, res, {\n  409: (body) => {\n    throw new Error(body.msg);\n  },\n});\n```\n\nThe 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.\n\nA runtime `TypeError` is thrown if the configured `statusField` doesn't hold a number on the response (catches malformed responses early).\n\n### Composable extractors\n\n`defaultExtractMessage` is composed from named primitives that each handle one body shape. Import them to build your own priority chain:\n\n```ts\nimport {\n  createExpectStatus,\n  chainExtractors,\n  stringBody, \u002F\u002F body itself, if non-empty string\n  messageField, \u002F\u002F body.message\n  problemDetail, \u002F\u002F RFC 7807 body.detail then body.title\n  arrayErrors, \u002F\u002F Laravel-style body.errors[0].message or first element\n  springError, \u002F\u002F Spring-style body.error (often just the HTTP status name)\n} from \"expect-status\";\n\n\u002F\u002F Use only RFC 7807 plus body.message:\nconst expectStatus = createExpectStatus({\n  extractMessage: chainExtractors(problemDetail, messageField),\n});\n\n\u002F\u002F Or write your own primitive and slot it in:\nconst myCodeField = (body: unknown) =>\n  typeof body === \"object\" && body !== null && \"reason\" in body\n    ? String(body.reason)\n    : undefined;\n\nconst expectStatus = createExpectStatus({\n  extractMessage: chainExtractors(myCodeField, messageField, problemDetail),\n});\n```\n\n`chainExtractors` returns the first non-undefined result from its inputs. Order matters — the leftmost extractor wins.\n\n### Status ranges and specifiers\n\nDispatch keys and the expected-status argument accept class-level ranges and named specifiers:\n\n| Specifier    | Matches                     |\n| ------------ | --------------------------- |\n| `'1xx'`      | 100–199 (informational)     |\n| `'2xx'`      | 200–299 (success)           |\n| `'3xx'`      | 300–399 (redirection)       |\n| `'4xx'`      | 400–499 (client errors)     |\n| `'5xx'`      | 500–599 (server errors)     |\n| `'success'`  | 200–299 (alias for `'2xx'`) |\n| `'error'`    | 400–599 (client + server)   |\n| `'!4xx'`     | Anything **except** 400–499 |\n| `'!success'` | Anything **except** 200–299 |\n\n```ts\n\u002F\u002F As expected status\nconst body = await expectStatus(\"success\", response);\nconst data = await expectStatus(\"!4xx\", response);\n\n\u002F\u002F In dispatch\nawait expectStatus(200, response, {\n  \"4xx\": \"Client error\",\n  \"5xx\": (body) => Sentry.captureMessage(JSON.stringify(body)),\n});\n```\n\nTens-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.\n\n### Opt-in exhaustiveness\n\nAdd `exhaustive: true` to require every error status be covered:\n\n```ts\nawait expectStatus(201, res, {\n  409: \"Conflict\",\n  422: \"Invalid input\",\n  exhaustive: true,\n});\n```\n\nA 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` \u002F `fallbackMessage`.\n\n### `recover` and `transform`\n\n```ts\n\u002F\u002F recover — catch-all that wraps the entire error path:\nconst result = await expectStatus(201, res, {\n  recover: (err) => ({ fallback: true, reason: err.message }),\n});\n\n\u002F\u002F transform — reshape the success body before returning:\nconst wrapped = await expectStatus(200, res, {\n  transform: (body) => ({ data: body, timestamp: Date.now() }),\n});\n```\n\n`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`.\n\n`transform` runs after `onSuccess` on the success path.\n\n### `throws: false` \u002F SafeResult\n\nReturns a typed `SafeResult\u003CT>` instead of throwing:\n\n```ts\nconst result = await expectStatus(200, res, { throws: false });\nif (result.ok) {\n  result.data; \u002F\u002F typed body\n} else {\n  result.error; \u002F\u002F Error\n  result.status; \u002F\u002F number\n  result.body; \u002F\u002F unknown\n}\n```\n\n### Custom groups\n\nDefine domain-specific status groups on the instance:\n\n```ts\nconst expectStatus = createExpectStatus({\n  groups: {\n    auth: [401, 403],\n    retryable: [408, 429, 503],\n  },\n});\n\n\u002F\u002F As expected status\nawait expectStatus(\"auth\", res);\n\n\u002F\u002F In dispatch\nawait expectStatus(200, res, {\n  auth: \"Please sign in.\",\n  retryable: (body) => retryQueue.add(body),\n});\n```\n\n### Adapter\n\nNormalize non-standard response shapes at the instance level. Use a built-in preset or write your own:\n\n```ts\nimport { createExpectStatus, adapters } from \"expect-status\";\n\nconst expectStatus = createExpectStatus({ adapter: adapters.axios });\n```\n\nSee [Adapter presets](#adapter-presets) for the full list. Custom adapters are just functions:\n\n```ts\nconst expectStatus = createExpectStatus({\n  adapter: (res) => ({ status: res.meta.httpStatus, body: res.result.data }),\n});\n```\n\nThe adapter runs first, before any dispatch logic. If no adapter is provided, the library reads `status`\u002F`body` directly (standard behaviour).\n\n### `ExpectStatusError`\n\nDefault error thrown by the default instance. Carries `status` and `body` for catch-block inspection.\n\n```ts\ntry {\n  await expectStatus(200, res);\n} catch (err) {\n  if (err instanceof ExpectStatusError) {\n    console.log(err.status, err.body, err.message);\n  }\n}\n```\n\n### Type helpers\n\n```ts\nimport type {\n  SafeResult,\n  StatusResponse,\n  StatusRange,\n  StatusGroup,\n  StatusSpecifier,\n  StatusArg,\n  SuccessArg,\n  AsStatuses,\n  ResolveSuccessBody,\n  ResolveErrorStatus,\n  ExpectStatusFn,\n  ExpectStatusOptions,\n  ExhaustiveCheck,\n  IsCovered,\n  StatusToClass,\n  UncoveredErrors,\n  StatusOf,\n  BodyOf,\n  ExtractBranch,\n  Extractor,\n} from \"expect-status\";\n\ntype CreateOrgResponse =\n  | { status: 201; body: Organisation }\n  | { status: 409; body: { message: string } };\n\ntype Organisation = ResolveSuccessBody\u003CCreateOrgResponse, 201>;\ntype EitherBody = ResolveSuccessBody\u003CCreateOrgResponse, readonly [201, 409]>;\n\u002F\u002F   ^? Organisation | { message: string }\n```\n\n### Utility exports\n\n```ts\nimport {\n  rangeOf, \u002F\u002F (status: number) => StatusRange | undefined  — e.g. rangeOf(404) → '4xx'\n  isStatusRange, \u002F\u002F (value: unknown) => value is StatusRange\n  isStatusGroup, \u002F\u002F (value: unknown) => value is StatusGroup\n  isStatusSpecifier, \u002F\u002F (value: unknown) => value is StatusSpecifier\n  matchesSpecifier, \u002F\u002F (status: number, spec: StatusSpecifier) => boolean\n  parseStatusArg, \u002F\u002F (arg: StatusArg) => number[] — expands ranges\n  matchesStatusArg, \u002F\u002F (status: number, arg: StatusArg) => boolean\n} from \"expect-status\";\n```\n\n## Real-world examples\n\n### TanStack Query\n\n`expect-status` pairs naturally with TanStack Query — the thrown error becomes the query's `error` state:\n\n```ts\nimport { useQuery, useMutation } from \"@tanstack\u002Freact-query\";\nimport { expectStatus } from \"expect-status\";\n\nfunction useOrganisation(id: string) {\n  return useQuery({\n    queryKey: [\"org\", id],\n    queryFn: () =>\n      expectStatus(200, client.getOrganisation({ params: { id } })),\n    \u002F\u002F       ^? () => Promise\u003COrganisation>\n  });\n}\n\nfunction useCreateOrganisation() {\n  return useMutation({\n    mutationFn: (data: CreateOrgInput) =>\n      expectStatus(201, client.createOrganisation({ body: data }), {\n        409: (body) => redirect(`\u002Forg\u002F${body.organisationId}`),\n        422: \"Invalid organisation details.\",\n      }),\n  });\n}\n```\n\n`expectStatus` returns a promise that resolves to the typed body or throws, which is exactly what TanStack Query expects.\n\n### TanStack Query with `throws: false`\n\nFor mutations where you want structured results instead of exceptions:\n\n```ts\nfunction useCreateOrganisation() {\n  return useMutation({\n    mutationFn: async (data: CreateOrgInput) => {\n      const result = await expectStatus(\n        201,\n        client.createOrganisation({ body: data }),\n        { throws: false },\n      );\n      if (!result.ok) {\n        return { error: result.error.message };\n      }\n      return { data: result.data };\n    },\n  });\n}\n```\n\n### Axios with adapter\n\nUse the adapter to avoid manually wrapping Axios responses:\n\n```ts\nimport axios from \"axios\";\nimport { createExpectStatus } from \"expect-status\";\n\nconst api = axios.create({\n  baseURL: \"https:\u002F\u002Fapi.example.com\",\n  validateStatus: () => true, \u002F\u002F don't throw on non-2xx\n});\n\nconst expectStatus = createExpectStatus({\n  adapter: (res) => ({ status: res.status, body: res.data }),\n  fallbackMessage: \"Request failed.\",\n  defaults: {\n    401: \"Please sign in.\",\n    \"5xx\": \"Service unavailable.\",\n  },\n});\n\n\u002F\u002F No need to manually wrap — the adapter handles { status, data } → { status, body }\nconst org = await expectStatus(201, api.post(\"\u002Forgs\", data));\n```\n\n### Form submissions\n\n```ts\nasync function onSubmit(formData: FormData) {\n  try {\n    const org = await expectStatus(\n      201,\n      client.createOrganisation({ body: formData }),\n      {\n        409: \"An organisation with that name already exists.\",\n        422: \"Please check the form and try again.\",\n      },\n    );\n    redirect(`\u002Forg\u002F${org.id}`);\n  } catch (err) {\n    \u002F\u002F err.message is the per-status message or the extracted backend message\n    toast.error(err.message);\n  }\n}\n```\n\n### Form submissions with `recover`\n\n```ts\nasync function onSubmit(formData: FormData) {\n  const result = await expectStatus(\n    201,\n    client.createOrganisation({ body: formData }),\n    {\n      409: \"An organisation with that name already exists.\",\n      422: \"Please check the form and try again.\",\n      recover: (err) => ({ error: err.message }),\n    },\n  );\n\n  if (\"error\" in result) {\n    toast.error(result.error);\n  } else {\n    redirect(`\u002Forg\u002F${result.id}`);\n  }\n}\n```\n\n### Error boundaries (React)\n\nErrors thrown by `expectStatus` propagate naturally to React error boundaries:\n\n```ts\n\u002F\u002F In a Server Component or loader\nasync function loadOrganisation(id: string) {\n  return expectStatus(200, client.getOrganisation({ params: { id } }), {\n    404: \"Organisation not found.\",\n  });\n}\n\u002F\u002F Non-success statuses throw → caught by the nearest ErrorBoundary\n```\n\n### Next.js Server Action\n\n```ts\n\"use server\";\n\nimport { expectStatus } from \"expect-status\";\nimport { redirect } from \"next\u002Fnavigation\";\n\nexport async function createOrganisation(formData: FormData) {\n  const org = await expectStatus(\n    201,\n    client.createOrganisation({ body: { name: formData.get(\"name\") } }),\n    {\n      409: \"An organisation with that name already exists.\",\n      422: \"Please check the form and try again.\",\n    },\n  );\n  redirect(`\u002Forg\u002F${org.id}`);\n}\n```\n\n### Compatible clients\n\n| Client            | Setup                                      | Preset                   |\n| ----------------- | ------------------------------------------ | ------------------------ |\n| **ts-rest**       | None — returns `{ status, body }` natively | —                        |\n| **Orval**         | `adapter: adapters.axios`                  | `adapters.axios`         |\n| **openapi-fetch** | `adapter: adapters.openapiClient`          | `adapters.openapiClient` |\n| **hey-api**       | `adapter: adapters.openapiClient`          | `adapters.openapiClient` |\n| **Axios**         | `adapter: adapters.axios`                  | `adapters.axios`         |\n| **Native fetch**  | `adapter: adapters.fetch` or `fetchExpect` | `adapters.fetch`         |\n| **Hand-rolled**   | None — if you return `{ status, body }`    | —                        |\n\n#### ts-rest (native)\n\n```ts\nconst org = await expectStatus(201, client.createOrganisation({ body: data }));\n```\n\n#### Orval \u002F Axios\n\n```ts\nimport { createExpectStatus, adapters } from \"expect-status\";\n\nconst expectStatus = createExpectStatus({ adapter: adapters.axios });\nconst org = await expectStatus(201, createOrganisation(data));\n```\n\n#### openapi-fetch\n\n```ts\nimport { createExpectStatus, adapters } from \"expect-status\";\n\nconst expectStatus = createExpectStatus({ adapter: adapters.openapiClient });\nconst user = await expectStatus(\n  200,\n  client.GET(\"\u002Fusers\u002F{id}\", { params: { path: { id } } }),\n);\n```\n\n#### hey-api\n\n```ts\nimport { createExpectStatus, adapters } from \"expect-status\";\n\nconst expectStatus = createExpectStatus({ adapter: adapters.openapiClient });\nconst org = await expectStatus(200, getOrganisation({ path: { id } }));\n```\n\n#### Hand-rolled typed fetch\n\n```ts\ntype FooResponse =\n  | { status: 200; body: Foo }\n  | { status: 404; body: { message: string } };\n\nasync function getFoo(id: string): Promise\u003CFooResponse> {\n  const r = await fetch(`\u002Ffoo\u002F${id}`);\n  return { status: r.status, body: await r.json() } as FooResponse;\n}\n\nconst foo = await expectStatus(200, getFoo(\"123\"));\n```\n\n## Comparison with `ts-pattern`\n\n[`ts-pattern`](https:\u002F\u002Fgithub.com\u002Fgvergnaud\u002Fts-pattern) is the idiomatic library for general pattern matching in TypeScript. You can use it for status dispatch:\n\n```ts\nimport { match } from \"ts-pattern\";\n\nconst result = match(res)\n  .with({ status: 201 }, (r) => r.body)\n  .with({ status: 409 }, (r) => {\n    redirect(`\u002Forg\u002F${r.body.organisationId}`);\n  })\n  .with({ status: 410 }, () => {\n    throw new Error(\"Expired\");\n  })\n  .otherwise((r) => {\n    throw new Error(r.body.message ?? \"Failed\");\n  });\n```\n\n`expect-status` is more terse for the common case (flat dispatch vs `.with()` chain) and bakes in:\n\n- **Backend message bubbling** — unhandled statuses fall through to `extractMessage(body)` automatically.\n- **Class-range catch-alls** — `'4xx'`, `'5xx'`, `'success'`, `'error'` keys.\n- **Named specifiers and negation** — `'!4xx'`, `'!success'` as expected status.\n- **Instance-wide defaults** — set once, reuse everywhere.\n- **`recover` catch-all** — return instead of throw, wraps the entire error path.\n- **`throws: false`** — structured `SafeResult\u003CT>` without try\u002Fcatch.\n- **Custom groups** — domain-specific status sets like `auth`, `retryable`.\n- **Observability hooks** — central error\u002Fsuccess logging at the dispatch layer.\n\n`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.\n\n## Non-goals\n\nA few things this library deliberately doesn't do:\n\n- **Non-numeric discriminators** (string tags like `{ tag: 'success' }`, GraphQL `__typename`, etc.) — these are general tagged-union pattern matching, which `ts-pattern` already handles cleanly. `expect-status` stays anchored on numeric HTTP-style status codes so it can offer class-range matchers (`'4xx'`, `'5xx'`) and the HTTP-aware exhaustive check.\n- **Tens-level ranges** (`'40x'`, `'42x'`) — real APIs differentiate 422 \u002F 429 \u002F 451, so a tens-range usually hides design intent. Use exact codes or custom groups.\n- **Schema validation, retries, sync variants** — different concerns; keep them at the layer where they belong (your codegen, your transport, your runtime). A thin `fetchExpect` helper is provided at `expect-status\u002Ffetch` for native `fetch` integration — see [Native fetch integration](#native-fetch-integration).\n\n## Migration from v1\n\n| v1                                                    | v2                                 |\n| ----------------------------------------------------- | ---------------------------------- |\n| `expectStatus(response, 200)`                         | `expectStatus(200, response)`      |\n| `{ handlers: { 409: fn }, messages: { 422: \"msg\" } }` | `{ 409: fn, 422: \"msg\" }`          |\n| `handleError: (err) => fallback`                      | `recover: (err) => fallback`       |\n| `handleSuccess: (body) => transformed`                | `transform: (body) => transformed` |\n| `defaults: { messages: { 401: \"Sign in\" } }`          | `defaults: { 401: \"Sign in\" }`     |\n\n## License\n\nMIT © zak-js\n","expect-status 是一个为 TypeScript 设计的类型安全 API 状态处理库，通过一致的模式提供全面的类型推断、合理的默认行为、可重用实例以及客户端适配器。其核心功能包括支持自动类型检查与转换、简化错误处理逻辑，并且能够自定义状态组和全局默认行为以适应不同业务场景的需求。特别适用于需要高类型安全保障的前端项目中，尤其是在使用 OpenAPI 定义的服务调用时，能够显著提高代码质量和开发效率。",2,"2026-06-11 03:56:36","CREATED_QUERY"]