Skip to content

Errors

Error classes, error-body parsers and error-event types used by the HTTP client. Everything the HTTP client throws is a UserError (or its subclass ApiResponseError), so you only ever need one catch shape.

import {
  UserError,
  ApiResponseError,
  defaultErrorBodyParser,
  makeResponseError,
  makeNetworkError,
} from '@msflib/typescript';
import type {
  ErrorBodyParser,
  ParsedApiError,
  ClientErrorDispatch,
  ApiErrorDetail,
  NetworkErrorDetail,
} from '@msflib/typescript';

Table of Contents


What gets thrown, when

Situation Thrown message
Non-2xx response, status < 500 ApiResponseError Parsed from the body (see shapes), or "Request failed"
Non-2xx response, status >= 500 ApiResponseError Always "Could not process request. Please contact support." - real message on error.cause.message
fetch itself rejects (offline, DNS, CORS...) UserError (not ApiResponseError) "Network error. Try again later." - original error on error.cause
2xx response whose body is not JSON (e.g. 204 No Content) Raw SyntaxError from response.json() (not a UserError) Runtime-specific

Empty / non-JSON success responses

Every client method calls response.json() on success. An endpoint that returns 204 No Content (common for DELETE) will reject with a plain SyntaxError, not a UserError. Wrap such calls yourself, or have the endpoint return a JSON body.


UserError

Base error class. Its message is always intended to be safe to show to an end user.

class UserError extends Error {
  name: 'UserError';
  cause?: unknown;
  constructor(message: string, cause?: unknown);
  static fromError(error: Error, message?: string): UserError;
}

UserError.fromError(err, msg?) wraps an existing error, keeping it on cause and using msg (or err.message) as the message.


ApiResponseError

Thrown for every non-2xx response. Extends UserError, so instanceof UserError checks keep working.

Property Type Notes
status number HTTP status code
statusText string HTTP status text
message string Parsed message, or the generic support message for 5xx
errors Record<string, string[]> (optional) Per-field validation errors, when the body shape provides them - for any status code
raw unknown The parsed JSON body. undefined if the body was not valid JSON
cause unknown For 5xx: an Error whose message is the real parsed server message. Otherwise undefined
import { createApiClient, ApiResponseError } from '@msflib/typescript';

const { apiClient } = createApiClient({
  baseURL: 'https://api.example.com',
});

try {
  await apiClient('POST', '/signup', payload);
} catch (error) {
  if (error instanceof ApiResponseError) {
    console.log(error.status); // 422
    console.log(error.message); // "Email already taken."
    console.log(error.errors); // { email: ["Email already taken."] }
  }
}

For 5xx responses, error.message is swapped for a generic "Could not process request. Please contact support." (so it's always safe to show a user directly), while the real server message is kept on error.cause.message. error.status, error.raw, and error.errors are preserved either way.

If the error body is not JSON (e.g. an HTML 502 page from a proxy), raw is undefined, the parsed message falls back to "Request failed", a warning is logged with console.warn, and no emitError event fires.


Supported error body shapes

error.message/error.errors are derived by defaultErrorBodyParser, which tries each of these in order and uses the first that matches:

  1. FastAPI: { detail: string } or { detail: [{ msg, loc, type }] }. For the array form, message is the first item's msg, and errors is keyed by the last element of each item's loc (e.g. ["body", "email"] becomes email).
  2. Laravel-style: { message: string, errors?: Record<string, string[]> }
  3. Generic: { error: string } or { errors: string[] } (message is the first entry)

If nothing matches, the message is "Request failed".


Custom error body parser

If your backend uses another shape, pass parseErrorBody to createApiClient. Your parser replaces the default one - it is not chained in front of it. Returning null means "no match" and produces the fallback message "Request failed"; the built-in shapes are not tried. To keep them as a fallback, call the exported defaultErrorBodyParser yourself:

import {
  createApiClient,
  defaultErrorBodyParser,
  type ErrorBodyParser,
} from '@msflib/typescript';

const parseErrorBody: ErrorBodyParser = (body, response) => {
  const b = body as { title?: string; violations?: Record<string, string[]> };
  if (!b?.title) return defaultErrorBodyParser(body, response); // fall back to the built-in shapes
  return { message: b.title, errors: b.violations };
};

const { apiClient } = createApiClient({
  baseURL: 'https://api.example.com',
  parseErrorBody,
});

The parser only runs when the body is valid JSON.


Error events (emitError)

createApiClient takes an optional second argument, an emitError callback of type ClientErrorDispatch. It is called in addition to the error being thrown - useful for global toasts, logging or analytics.

const { apiClient } = createApiClient(
  { baseURL: 'https://api.example.com' },
  (eventName, eventType, detail) => {
    // eventName: 'api-response-error' | 'network-error'
    // eventType: 'ApiError' | 'NetworkError'
    console.error(eventName, detail);
  },
);
eventName eventType detail Fires when
'api-response-error' 'ApiError' ApiErrorDetail Non-2xx response with a JSON body
'network-error' 'NetworkError' NetworkErrorDetail fetch rejects

ApiErrorDetail.message is the parsed message (the real one, even for 5xx). loc and type are taken from the first FastAPI detail item when present, otherwise [] and ''.


Low-level helpers

These are what createApiClient uses internally. You only need them if you are building your own fetch wrapper.

makeResponseError(
  response: Response,
  emitError?: ClientErrorDispatch,
  parseErrorBody?: ErrorBodyParser, // defaults to defaultErrorBodyParser
): Promise<UserError>; // always resolves to an ApiResponseError

makeNetworkError(
  error: Error,
  emitError?: ClientErrorDispatch,
): Promise<UserError>;

defaultErrorBodyParser: ErrorBodyParser;

Types

interface ParsedApiError {
  message: string;
  errors?: Record<string, string[]>;
}

// Return null for "this shape doesn't match"
type ErrorBodyParser = (body: unknown, response: Response) => ParsedApiError | null;

type ClientErrorDispatch = (
  eventName: string,
  eventType: 'ApiError' | 'NetworkError',
  detail: ApiErrorDetail | NetworkErrorDetail,
) => void;

interface ApiErrorDetail {
  status: number;
  statusText: string;
  message: string;
  loc?: unknown[];
  type?: string;
  errors?: Record<string, string[]>;
  raw?: unknown;
}

interface NetworkErrorDetail {
  name: string;
  message: string;
  cause?: unknown;
  stack?: string;
}

See also: HTTP client · Quick Start · Overview