Skip to content

Examples

Every example assumes a status-discriminated response union like this:

type CreateOrgResponse =
| { status: 201; body: { id: string; name: string } }
| { status: 409; body: { message: string; orgId: string } }
| { status: 422; body: { message: string; field: string } }
| { status: 500; body: { message: string } }

import { expectStatus } from 'expect-status'
const org = await expectStatus(201, client.createOrg({ body: data }))
// ^? { id: string; name: string }

Non-201 statuses throw an ExpectStatusError with a message extracted from the response body.

const result = await expectStatus([200, 201], client.upsert({ body: data }))
// ^? body of 200 | body of 201
// Built-in groups
await expectStatus('success', client.health()) // 200-299
await expectStatus('error', response) // 400-599
// Ranges
await expectStatus('2xx', response) // 200-299
await expectStatus('4xx', response) // 400-499
await expectStatus('5xx', response) // 500-599
// Negation
await expectStatus('!4xx', response) // anything except 400-499
await expectStatus('!error', response) // anything except 400-599
// Mixed arrays
await expectStatus([200, '3xx'], response) // 200 or any 300-399
await expectStatus(['success', 404], response) // any 2xx or 404

Strings auto-throw an ExpectStatusError with that message:

await expectStatus(201, client.createOrg({ body: data }), {
409: 'An organisation with that name already exists.',
422: 'Please check your input.',
})

Functions receive the typed body and can return or throw:

const org = await expectStatus(201, client.createOrg({ body: data }), {
409: ({ orgId }) => redirect(`/org/${orgId}`), // returns → becomes the result
422: (body) => { throw new Error(body.message) }, // throws → propagates
})

Catch entire HTTP classes. Exact codes always take priority:

await expectStatus(200, response, {
404: 'Not found.', // exact code — highest priority
'4xx': 'Client error.', // range — catches 400, 401, 403, ...
'5xx': 'Service is temporarily unavailable.',
})

Handlers can return values, widening the return type:

const result = await expectStatus(201, client.createOrg({ body: data }), {
409: (body) => ({ conflict: true, orgId: body.orgId }),
})
// result: { id: string; name: string } | { conflict: true; orgId: string }

Define domain-specific status groups on the instance:

const expectStatus = createExpectStatus({
groups: {
auth: [401, 403],
retryable: [408, 429, 500, 502, 503, 504],
},
})
// As expected status
await expectStatus('auth', response)
// In dispatch
await expectStatus(200, response, {
auth: () => redirect('/sign-in'),
retryable: 'Please try again shortly.',
})
// Negated
await expectStatus('!auth', response)

recover wraps the entire error path. If it returns a non-undefined value, that becomes the result:

const config = await expectStatus(200, client.getFeatureFlags(), {
recover: () => DEFAULT_FLAGS,
})
// config: FeatureFlags — never throws

Reshape the success body before returning:

const wrapped = await expectStatus(200, response, {
transform: (body) => ({ data: body, fetchedAt: Date.now() }),
})
// wrapped: { data: User; fetchedAt: number }

Get a discriminated union instead of throwing:

const result = await expectStatus(200, client.getUser(id), {
throws: false,
})
if (result.ok) {
result.data // typed body
} else {
result.error // Error
result.status // number
result.body // unknown
}

TypeScript flags uncovered error statuses at compile time:

await expectStatus(201, client.createOrg({ body: data }), {
409: 'Conflict',
422: 'Invalid input',
500: 'Server error',
exhaustive: true, // ✅ compiles — all error statuses covered
})
await expectStatus(201, client.createOrg({ body: data }), {
409: 'Conflict',
exhaustive: true, // ❌ type error — missing 422, 500
})

Fire side-effects on every error or success without affecting the result:

const expectStatus = createExpectStatus({
onError: (err, response) => {
Sentry.captureException(err, {
extra: { status: response.status, body: response.body },
})
},
onSuccess: (response) => {
analytics.track('api_success', { status: response.status })
},
})

Per-call hooks shadow instance hooks:

await expectStatus(200, response, {
onError: (err) => customLogger.warn(err.message),
})

Set shared handlers and messages for your entire app:

import { createExpectStatus } from 'expect-status'
export const expectStatus = createExpectStatus({
fallbackMessage: 'Something went wrong. Please try again.',
groups: { auth: [401, 403] },
defaults: {
auth: 'Please sign in or check your permissions.',
429: 'Too many requests. Please slow down.',
'5xx': 'Service is temporarily unavailable.',
},
onError: (err, res) => {
Sentry.captureException(err, { extra: { status: res.status } })
},
})
// Per-call dispatch shadows defaults
await expectStatus(200, response, {
'5xx': (body) => customFallback(body), // shadows instance '5xx'
})

Replace ExpectStatusError with your own:

class ApiError extends Error {
constructor(message: string, public status: number, public body: unknown) {
super(message)
}
}
const expectStatus = createExpectStatus({
errorFactory: (message, response) =>
new ApiError(message, response.status, response.body),
})

Customise how messages are pulled from response bodies:

import {
createExpectStatus,
chainExtractors,
problemDetail,
messageField,
} from 'expect-status'
const expectStatus = createExpectStatus({
extractMessage: chainExtractors(problemDetail, messageField),
})

Write your own extractor:

const reasonField = (body: unknown) =>
typeof body === 'object' && body !== null && 'reason' in body
? String((body as { reason: string }).reason)
: undefined
const expectStatus = createExpectStatus({
extractMessage: chainExtractors(reasonField, problemDetail, messageField),
})

Normalize non-standard response shapes at the instance level:

import axios from 'axios'
import { createExpectStatus } from 'expect-status'
const api = axios.create({
baseURL: 'https://api.example.com',
validateStatus: () => true,
})
const expectStatus = createExpectStatus({
adapter: (res) => ({ status: res.status, body: res.data }),
})
const org = await expectStatus(201, api.post('/orgs', data))
const expectStatus = createExpectStatus({
adapter: (res) => ({
status: res.meta.httpStatus,
body: res.result.data,
}),
})
const expectStatus = createExpectStatus({
adapter: async (res) => ({
status: res.status,
body: await res.json(),
}),
})
const user = await expectStatus(200, fetch('/api/user'))
import { fetchExpect } from 'expect-status/fetch'
type UserResponse =
| { status: 200; body: { id: string; name: string } }
| { status: 404; body: { message: string } }
const user = await fetchExpect<UserResponse>(
'https://api.example.com/users/1',
200,
{
404: 'User not found',
init: { headers: { Authorization: 'Bearer token' } },
},
)

For APIs that use different field names:

const expectStatus = createExpectStatus({
statusField: 'code',
bodyField: 'payload',
})
type Response =
| { code: 200; payload: { id: string } }
| { code: 404; payload: { message: string } }
const item = await expectStatus(200, response)

function useOrganisation(id: string) {
return useQuery({
queryKey: ['org', id],
queryFn: () => expectStatus(200, client.getOrg({ params: { id } })),
})
}
function useCreateOrganisation() {
return useMutation({
mutationFn: (data: CreateOrgInput) =>
expectStatus(201, client.createOrg({ body: data }), {
409: (body) => redirect(`/org/${body.orgId}`),
422: 'Invalid organisation details.',
}),
})
}
async function onSubmit(formData: FormData) {
try {
const org = await expectStatus(201, client.createOrg({ body: formData }), {
409: 'An organisation with that name already exists.',
422: 'Please check the form and try again.',
})
redirect(`/org/${org.id}`)
} catch (err) {
toast.error(err.message)
}
}
async function onSubmit(formData: FormData) {
const result = await expectStatus(201, client.createOrg({ body: formData }), {
409: 'An organisation with that name already exists.',
recover: (err) => ({ error: err.message }),
})
if ('error' in result) {
toast.error(result.error)
} else {
redirect(`/org/${result.id}`)
}
}
'use server'
import { expectStatus } from '@/lib/expect-status'
import { redirect } from 'next/navigation'
export async function createOrganisation(formData: FormData) {
const org = await expectStatus(201, client.createOrg({
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}`)
}
async function loadOrganisation(id: string) {
return expectStatus(200, client.getOrg({ params: { id } }), {
404: 'Organisation not found.',
})
}
// Non-success statuses throw → caught by the nearest ErrorBoundary
function useCreateOrganisation() {
return useMutation({
mutationFn: async (data: CreateOrgInput) => {
const result = await expectStatus(201, client.createOrg({ body: data }), {
throws: false,
})
if (!result.ok) {
return { error: result.error.message }
}
return { data: result.data }
},
})
}
// Strict instance for user-facing calls
export const expectStatus = createExpectStatus({
fallbackMessage: 'Something went wrong.',
onError: (err) => Sentry.captureException(err),
})
// Lenient instance for background jobs
export const expectBg = createExpectStatus({
fallbackMessage: 'Background task failed.',
onError: (err) => logger.warn(err.message),
})

For full framework-specific setup, see: