npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

expect-status

v2.0.0

Published

Type-safe API status handling for TypeScript: one consistent pattern with full inference, sensible fallbacks, reusable instances, and client adapters.

Readme

expect-status

Type-safe API status handling for TypeScript: one consistent pattern with full inference, sensible fallbacks, reusable instances, and client adapters.

npm version npm downloads CI

Documentation · Quick Start · API Reference

Before / after

// ❌ Without expect-status

const res = await client.POST("/organisations", { body: data });

if (res.response.status === 201) return res.data!; // unsafe assertion
if (res.response.status === 409) {
  return router.push(`/org/${(res.error as any).organisationId}`); // manual cast
}
if (res.response.status === 422) {
  setFieldErrors((res.error as any).fieldErrors); // manual cast
  throw new Error((res.error as any).message); // manual cast
}
if (res.response.status === 401) return router.push("/login"); // where do shared defaults live?
if (res.response.status === 403) throw new Error("No permission."); // how do you override this per-call?
if (res.response.status >= 500) {
  Sentry.captureException(new Error("API error")); // no central hook
  throw new Error("Service unavailable.");
}
throw new Error((res.error as any)?.message ?? "Unknown error"); // pray the shape is right
// ✅ With expect-status

const org = await expectStatus(
  201,
  client.POST("/organisations", { body: data }),
  {
    409: ({ organisationId }) => router.push(`/org/${organisationId}`), // ← typed from 409 branch
    422: ({ fieldErrors, message }) => {
      // ← typed from 422 branch
      setFieldErrors(fieldErrors);
      throw new Error(message);
    },
  },
);
// ^ org: Organisation — inferred from 201 branch, no casts
// ^ 401, 403, 5xx, Sentry — handled by instance defaults
// ^ any other status auto-throws with body.message extracted

Setup (once)

// lib/expect-status.ts — define once, import everywhere

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

export const expectStatus = createExpectStatus({
  adapter: adapters.openapiClient, // normalize openapi-fetch / hey-api responses

  // Sensible fallbacks — handle common statuses app-wide so call sites don't have to
  defaults: {
    401: () => router.push("/login"), // function → handler (runs logic, can return/throw)
    403: "You do not have permission.", // string → auto-throws with this message
    429: "Too many requests. Try again later.",
    "5xx": "Service unavailable.", // status range — catches 500, 502, 503, 504, etc.
    "!success": "Something went wrong.", // negation — fallback for anything outside 2xx
  },

  // Custom groups — domain-specific status sets you can use as specifiers
  groups: { auth: [401, 403] }, // now you can do: expectStatus('auth', ...)

  // Observability — fires on every error/success across the app
  onError: (err, res) =>
    Sentry.captureException(err, { tags: { status: String(res.status) } }),
  onSuccess: (res) => analytics.track("api_success", { status: res.status }),
});

Usage

// Simple — just expect a status, everything else falls through to defaults
const org = await expectStatus(
  201,
  client.POST("/organisations", { body: data }),
);
// ^ org: Organisation — typed, no cast
// ^ 401 → redirects, 403 → throws, 5xx → throws — all from defaults

// Per-call handlers — shadow defaults when this endpoint needs special behavior
const org = await expectStatus(
  201,
  client.POST("/organisations", { body: data }),
  {
    409: ({ organisationId }) => router.push(`/org/${organisationId}`),
    422: ({ fieldErrors }) => {
      setFieldErrors(fieldErrors);
      throw new Error("Fix fields.");
    },
    401: "Session expired. Please sign in again.", // ← shadows the instance's 401 default
  },
);

// Multiple success codes
const member = await expectStatus(
  [200, 201],
  client.POST("/members", { body: invite }),
);
// ^ typed as body of 200 | body of 201

// Status specifiers
await expectStatus("success", client.GET("/health")); // any 2xx
await expectStatus("!error", client.GET("/probe")); // anything except 4xx/5xx
await expectStatus("auth", client.GET("/me")); // custom group: [401, 403]
await expectStatus([200, "3xx"], client.GET("/follow")); // mixed array

// Graceful fallback — never throws
const flags = await expectStatus(200, client.GET("/feature-flags"), {
  recover: () => DEFAULT_FLAGS,
});

// Reshape success body
const user = await expectStatus(
  200,
  client.GET("/users/{id}", { params: { path: { id } } }),
  {
    transform: (body) => ({ ...body, fetchedAt: Date.now() }),
  },
);

// Non-throwing mode — typed SafeResult
const result = await expectStatus(200, client.GET("/config"), {
  throws: false,
});
if (result.ok)
  console.log(result.data); // typed success body
else console.error(result.error); // ExpectStatusError

Why teams use it

  • Consistent status handling across the app — not per-call custom branching
  • Full type inference for success and error bodies by status
  • Sensible fallbacks via messages, ranges ('4xx', '5xx'), and recover
  • Reusable instances with shared defaults, hooks, and custom groups
  • Adapter presets for Axios, Orval, openapi-fetch, hey-api, and native fetch

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

Best results with status-discriminated responses

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:

type Response =
  | { status: 201; body: Organisation }
  | { status: 409; body: { organisationId: string } }
  | { status: 422; body: { fieldErrors: Record<string, string> } };

Which tools give you this?

| Tool | Full per-status inference? | Notes | | ------------------------------------ | -------------------------- | ----------------------------------------------- | | ts-rest | ✅ Yes | Returns { status, body } unions natively | | openapi-typescript + typed fetch | ✅ Yes | You control the response shape | | openapi-fetch | Success body only | Error bodies aren't discriminated — use adapter | | hey-api | Success body only | Same as above | | Axios / Orval | Success body only | Use adapters.axios |

For full type inference on every branch (success AND error handlers), we recommend a codegen that emits status-discriminated unions:

  • ts-rest — if you control both client and server
  • openapi-typescript + a thin typed fetch wrapper — if you have an OpenAPI spec

Without a codegen

Don'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:

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

// Instance-level error map — typed once, applies to all calls
const expectStatus = createExpectStatus<{
  401: { message: string };
  403: { message: string };
  422: { fieldErrors: Record<string, string>; message: string };
}>({
  // Custom adapter — normalize any response shape to { status, body }
  adapter: (res) => ({
    status: res.meta.code,
    body: res.result,
  }),

  defaults: {
    401: ({ message }) => router.push("/login"), // ← typed from instance error map
    403: "No permission.",
    422: ({ fieldErrors }) => setFieldErrors(fieldErrors), // ← typed from instance error map
    "5xx": "Service unavailable.",
  },
});

// Success body inferred from client — error handlers typed from instance map
const org = await expectStatus(201, api.createOrg(data));

// Per-call error types — use typed() for inline inference
const org = await expectStatus(
  201,
  api.createOrg(data),
  typed<{
    409: { organisationId: string };
  }>({
    409: ({ organisationId }) => router.push(`/org/${organisationId}`), // ← typed
  }),
);

Or define your response types manually with the StatusMap helper:

import { expectStatus, type StatusMap } from "expect-status";

type CreateOrgResponse = StatusMap<{
  201: Organisation;
  409: { organisationId: string };
  422: { fieldErrors: Record<string, string> };
}>;
// ^ expands to { status: 201; body: Organisation } | { status: 409; ... } | ...

const res = (await createOrg(data)) as CreateOrgResponse;
const org = await expectStatus(201, res);
// ^ full per-status inference, no codegen

Still useful without any type annotations

Even without per-status types, expect-status gives you centralized defaults, observability hooks, status ranges, message bubbling, recover/transform, and a consistent dispatch pattern across your app. Handler bodies will be unknown — you annotate inline only where you need to destructure.

Single API layer (optional)

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

This is not required. If you already have a typed client (ts-rest, openapi-fetch, Axios), just use expectStatus(status, promise) directly.

import { createExpectStatus } from "expect-status";

const api = createExpectStatus<{
  401: { message: string };
  403: { message: string };
  409: { organisationId: string };
  422: { fieldErrors: Record<string, string>; message: string };
}>({
  fetcher: (url, init) =>
    fetch(`https://api.example.com${url}`, {
      ...init,
      headers: { ...init?.headers, Authorization: `Bearer ${getToken()}` },
    }),
  defaults: {
    401: ({ message }) => router.push("/login"),
    403: "You do not have permission.",
    "5xx": "Service unavailable.",
  },
  onError: (err, res) => Sentry.captureException(err),
});

// GET
const user = await api.get<User>("/users/me", 200);

// POST — body is typed via second generic
const org = await api.post<Organisation, CreateOrgInput>(
  "/organisations",
  data,
  201,
  {
    409: ({ organisationId }) => router.push(`/org/${organisationId}`), // ← typed from instance map
    422: ({ fieldErrors }) => setFieldErrors(fieldErrors), // ← typed from instance map
  },
);

// PUT
const updated = await api.put<Organisation, UpdateOrgInput>(
  "/organisations/123",
  changes,
  200,
);

// DELETE
await api.delete("/organisations/123", 204);

// Override fetcher options per-call (headers, signal, etc.)
const flags = await api.get<FeatureFlags>("/feature-flags", 200, {
  headers: { "X-Region": "eu-west-1" },
  signal: abortController.signal,
  recover: () => DEFAULT_FLAGS,
});
// Axios — custom adapter normalizes the response
const api = createExpectStatus({
  fetcher: (url, init) =>
    axios({
      url,
      method: init?.method,
      data: init?.body,
      headers: init?.headers,
    }),
  adapter: (res) => ({ status: res.status, body: res.data }),
  defaults: { 401: () => router.push("/login"), "5xx": "Service unavailable." },
});

Method helpers auto-set Content-Type: application/json 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.

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