Skip to content

Zak-JS/expect-status

Repository files navigation

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}`),
    422: "Please fix the highlighted fields.",
  },
);

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

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

About

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

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors