Skip to content

Zak-JS/expect-status

Repository files navigation

expect-status

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.

Why?

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 7807 detail, 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 hooksonError / onSuccess for logging, metrics, Sentry — without coupling to your transport
  • recover catch-all — wraps the entire error path, returns a fallback value instead of throwing
  • throws: false — returns a typed SafeResult<T> instead of throwing
  • Custom groupsgroups: { auth: [401, 403] } for domain-specific status sets
  • Compile-time exhaustiveness — opt-in exhaustive: true ensures every error status is handled

Install

npm install expect-status
# or
pnpm add expect-status
# or
yarn add expect-status

Requires TypeScript 5.4+ for infer T extends U and template literal status-class inference. Node 18+.

Quick start

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
}

Native fetch integration

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 fetch with the URL and any init options
  • Parses the response as JSON
  • Validates the status using expectStatus
  • Returns the typed body on success

Adapter presets

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.

How it resolves a non-success status

Handlers (functions) are always checked before messages (strings), even across per-call and instance defaults:

  1. Per-call handler — most specific match (exact code → range → group).
  2. Instance default handler — most specific match.
  3. Per-call message — most specific match.
  4. Instance default message — most specific match.
  5. extractMessage(body) — pulls a message from the response body.
  6. fallbackMessage — last-resort static string.
  7. onError fires once with the resolved error just before it's thrown.
  8. recover — true catch-all; if it returns a non-undefined value, 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.

API

expectStatus(successStatus, response, dispatch?)

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.

createExpectStatus(options?)

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 },
    }),
});

Options

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.

Custom field names

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

Composable extractors

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.

Status ranges and specifiers

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.

Opt-in exhaustiveness

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 and transform

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

throws: false / SafeResult

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
}

Custom groups

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),
});

Adapter

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

ExpectStatusError

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);
  }
}

Type helpers

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 }

Utility exports

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";

Real-world examples

TanStack Query

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.

TanStack Query with throws: false

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 };
    },
  });
}

Axios with adapter

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));

Form submissions

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);
  }
}

Form submissions with recover

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}`);
  }
}

Error boundaries (React)

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

Next.js Server Action

"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}`);
}

Compatible clients

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 }

ts-rest (native)

const org = await expectStatus(201, client.createOrganisation({ body: data }));

Orval / Axios

import { createExpectStatus, adapters } from "expect-status";

const expectStatus = createExpectStatus({ adapter: adapters.axios });
const org = await expectStatus(201, createOrganisation(data));

openapi-fetch

import { createExpectStatus, adapters } from "expect-status";

const expectStatus = createExpectStatus({ adapter: adapters.openapiClient });
const user = await expectStatus(
  200,
  client.GET("/users/{id}", { params: { path: { id } } }),
);

hey-api

import { createExpectStatus, adapters } from "expect-status";

const expectStatus = createExpectStatus({ adapter: adapters.openapiClient });
const org = await expectStatus(200, getOrganisation({ path: { id } }));

Hand-rolled typed fetch

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"));

Comparison with ts-pattern

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.
  • recover catch-all — return instead of throw, wraps the entire error path.
  • throws: false — structured SafeResult<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.

Non-goals

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, 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.
  • 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 fetchExpect helper is provided at expect-status/fetch for native fetch integration — see Native fetch integration.

Migration from v1

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" }

License

MIT © zak-js