Skip to content

Latest commit

 

History

History
266 lines (208 loc) · 11 KB

API.md

File metadata and controls

266 lines (208 loc) · 11 KB

API guide

Everything @gainratio/errors exports, with examples. For how classify decides and why, see Architecture. All examples run against v0.4.0, the current version on npm.

The smallest loop

import { corePack, defineErrorsWith } from "@gainratio/errors";

const errors = defineErrorsWith({}, corePack);

// A raw failure from anywhere: fetch, an SDK, a thrown DOMException.
const code = errors.classify({ status: 429, message: "Slow down" });

console.log(code); // "http.rate_limited"
console.log(errors.describe(code)); // "Too many requests. Wait a moment and try again."

For the full loop (register, classify, show it in two languages, send it as JSON), clone the repo and run pnpm demo. It builds the package and runs examples/quickstart.mjs.

Add your own codes

import { corePack, defineErrorsWith } from "@gainratio/errors";

// Start from the 10 everyday codes and add your own on top.
export const errors = defineErrorsWith(
  {},
  {
    ...corePack,
    "shop.out_of_stock": {
      category: "provider",
      params: ["sku"], // the typed contract for this code's params
      en: "Sorry, {sku} is sold out.",
    },
  },
);

errors.describe("shop.out_of_stock", { sku: "MUG-12" });
// "Sorry, MUG-12 is sold out."

Pass packs as separate arguments (defineErrorsWith({}, corePack, aiPack)) to get a startup error if two of them define the same code. Spread them into one object to get the richest param typing.

Classify a raw failure where it happens

try {
  await callApi();
} catch (err) {
  const code = errors.classify(err); // e.g. { status: 429 } -> "http.rate_limited"
  showError(errors.describe(code, {}, t)); // shown through YOUR i18next `t`
}

describe calls t("errors.<code>", params). If that key is not translated yet, it falls back to the catalog's English, so a raw key never reaches the screen. t can be anything shaped like t(key, params):

const es: Record<string, string> = {
  "errors.http.rate_limited": "Demasiadas solicitudes.",
};
const t = (key: string) => es[key] ?? key;

errors.describe("http.rate_limited", {}, t); // "Demasiadas solicitudes."
errors.describe("http.not_found", {}, t); // "That was not found on the server." (English fallback)

Set i18nKey on a catalog entry to use a different translation key.

Show errors in the user's language

The package ships Spanish (es) and Brazilian Portuguese (pt) for all 24 built-in codes in corePack, aiPack, bundlePack, and starterPack. They are plain i18next JSON, nested the way i18next reads keys by default (errors → http → rate_limited), with i18next's {{name}} placeholders:

import es from "@gainratio/errors/locales/es.json" with { type: "json" };
import pt from "@gainratio/errors/locales/pt.json" with { type: "json" };

i18next.addResourceBundle("es", "translation", es, true, false);
i18next.addResourceBundle("pt", "translation", pt, true, false);

errors.describe("http.rate_limited", {}, i18next.t);
// es: "Demasiadas solicitudes. Espera un momento e inténtalo de nuevo."
  • Which namespace. describe asks for errors.<code> in your default namespace. If your error strings live in another namespace, load the files there and set i18nKey: "myns:errors.http.rate_limited" on the entry, or wrap t so it adds the namespace.
  • Flat keys. If you set keySeparator: false, i18next wants flat keys such as "errors.http.rate_limited". Flatten the files once when you load them.
  • Turn off HTML escaping. Set interpolation: { escapeValue: false }. describe returns plain text, like its English, and you escape it where you render it. With i18next's default, values come back HTML-escaped (/ becomes &#x2F;), in the screen text and in a Problem Details title.
  • Which language. i18next picks it, from lng and fallbackLng. A regional language like pt-BR reads the pt file, and a language with no file falls back to English. The package itself never picks a language. The pt file is Brazilian Portuguese, so pt-PT readers get Brazilian wording.
  • No English file. English comes from each entry's en, so a registry built from starterPack keeps its own English wording.
  • Your own codes. Add errors.<code> keys to your translation files, or set i18nKey to reuse a key you already have. A key you have not translated yet shows the English sentence, never the raw key.

A server that answers in the caller's language passes the translated sentence as the title. The type and every param stay the same, so clients can still match on the code:

const t = i18next.getFixedT(userLanguage); // e.g. "pt-BR", from your own negotiation
errors.toProblemDetails(code, params, { title: errors.describe(code, params, t) });

Send it from an API (RFC 9457)

errors.toProblemDetails("http.rate_limited", { retryAfter: 30 });
// => { retryAfter: 30,
//      type: "http.rate_limited",
//      title: "Too many requests. Wait a moment and try again.",
//      status: 429 }

status comes from the entry's first httpStatus. Pass { status } or { instance } in the third argument to set them. Params become public fields in the body, so never pass a secret. Reserved and unsafe names are dropped; the full list is in Architecture.

Throw a coded error when you already know the cause

import { CanonicalError } from "@gainratio/errors";

// You know what happened, so there is nothing to classify.
throw errors.create("config.missing", { field: "STRIPE_KEY" });

// or without a registry:
new CanonicalError("config.missing", "config", { field: "STRIPE_KEY" });

A CanonicalError carries code, params, and category. Its message is the code itself ("config.missing"), not the English sentence. Call errors.describe(err.code, err.params) when you need the sentence.

Choose your own fallback code

classify returns the fallback code when nothing else matches. It is internal.unknown (exported as DEFAULT_FALLBACK_CODE) unless you set one:

import { defineErrorsWith } from "@gainratio/errors";

export const errors = defineErrorsWith({ fallbackCode: "shop.unknown" }, shopCodes);

errors.classify({ weird: true }); // "shop.unknown"

defineErrorsWith checks that the fallback code is in your catalog. If not, it throws UnregisteredFallbackError at startup. This also means aiPack on its own throws, because it does not include internal.unknown. The error says so and names both fixes: add corePack, or set a fallbackCode that aiPack has:

defineErrorsWith({ fallbackCode: "ai.provider.server_error" }, aiPack);

defineErrors(...fragments) is the older entry point. It always falls back to internal.unknown and does not check it.

Rule order and priority

A broad match rule registered first can claim failures meant for a later, more specific rule. priority fixes that. It defaults to 0. Higher runs earlier, and negative runs last.

const errors = defineErrorsWith(
  {},
  {
    ...corePack,
    "db.query.timeout": {
      category: "timeout",
      priority: 10, // beats any default-priority rule, wherever it was registered
      match: (raw) => /db .*timeout/i.test(String((raw as Error)?.message ?? "")),
      en: "The database query timed out.",
    },
  },
);

To write your own match rules, use the exported helpers errorNameOf, errorTextOf, and httpStatusOf. They read those fields from any thrown value without crashing on odd inputs.

Categories

category is how your UI and telemetry decide what to do: retry, open settings, free up space. Seven are built in and exported as KnownCategory:

provider · config · network · timeout · device · integrity · internal

You can use any other string too. Category is KnownCategory | (string & Record<never, never>), so the seven still autocomplete in an editor, and auth, validation, or billing type-check without patching this library.

const errors = defineErrorsWith(
  {},
  {
    ...corePack,
    "auth.session_expired": {
      category: "auth", // not one of the seven, still valid
      en: "Your session expired. Sign in again.",
    },
  },
);

Configuration

There are no environment variables and no config files. The only registry-wide setting is fallbackCode, passed as the first argument to defineErrorsWith. Everything else is your catalog.

Reference

Export Kind What it does
defineErrors fn Registers one or more catalog fragments and returns a typed Registry
defineErrorsWith fn defineErrors plus RegistryOptions (today: a checked fallbackCode)
DEFAULT_FALLBACK_CODE const "internal.unknown", the fallback when you set none
UnregisteredFallbackError class Thrown when the fallbackCode is not in the catalog
corePack const 10 everyday codes, the pack to start from
aiPack const 9 ai.* codes, for apps that call an AI model provider
bundlePack const 5 bundle.* codes, for apps that download a file to the device
starterPack const The original 18 codes, frozen for the repos that vendor them
CanonicalError class Error subclass carrying { code, params, category }
DuplicateCodeError class Thrown when a code is defined in two fragments
InvalidCatalogEntryError class Thrown when an entry has the wrong shape, e.g. httpStatus: 408 instead of [408]
errorNameOf / errorTextOf / httpStatusOf fn Helpers for writing your own match rules
@gainratio/errors/locales/es.json, pt.json data Spanish and Portuguese for every built-in code, as i18next JSON

Registry methods: classify(raw), describe(code, params?, t?) (t is any Translator, including i18next's t as-is), toProblemDetails(code, params?, opts?), problemFor(raw, params?, opts?), create(code, params?), plus codes, has, and get.

CatalogEntry fields: category (required), en, params, i18nKey, httpStatus, httpStatusRange, problemType, match, priority.

Types: Catalog, CatalogEntry, Category, KnownCategory, ErrorCode, MatchRule, Params, ParamsFor, ParamValue, ProblemDetails, ProblemOptions, Registry, RegistryOptions, TFunction, Translator.