Skip to content
gainratioPublic

About

Turns any error your app hits into one stable code and one clear message, for developers tired of mismatched error text.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

@gainratio/errors

Give every error in your JavaScript or TypeScript app one fixed code and one clear message, the same on every screen.

npm install @gainratio/errors (Node 22.13 or newer, no dependencies).

CI npm License: MIT

In most apps, every catch block writes its own error message, so one problem shows up in several different ways. An AI provider says "402 Payment Required", and one screen says "out of credits" while another says "check your model settings". Your logs use a third wording and your API sends a fourth. Nobody can search the logs for one failure, and users get told the wrong thing.

This package gives you one list of error codes for your whole app. You hand it whatever failed (an HTTP status, a thrown error, a network drop) and it gives back one code such as ai.provider.out_of_credits, one sentence a person can read, and the same error as a standard JSON body your API can send. The code is the same in every language. The sentence comes in the user's language through your own i18next, and Spanish and Portuguese translations of every built-in code ship with the package. It does not log, retry, or report errors. Your app still decides what to do with them.

Technical docs: Architecture · API guide · Getting started for developers

Try it

  1. Make an empty project and install the package:

    mkdir try-errors && cd try-errors && npm init -y >/dev/null && npm install @gainratio/errors
  2. Save this as try.mjs (it is also examples/try.mjs):

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

// Your app's list of error codes: 10 everyday ones + 9 for calling an AI service.
// Both lists claim some statuses (401, 404, 429, 5xx, timeouts). The list you
// pass first wins those, so here a 429 is http.rate_limited, not the ai.* code.
const errors = defineErrorsWith({}, corePack, aiPack);

// Four raw failures, the way they actually reach a catch block.
const failures = [
  { status: 402 }, // fetch() got "402 Payment Required"
  { status: 429 }, // fetch() got "429 Too Many Requests"
  new TypeError("Failed to fetch"), // the network dropped
  { status: 418, message: "I'm a teapot" }, // something nobody planned for
];

for (const raw of failures) {
  const code = errors.classify(raw);
  console.log(code.padEnd(28), errors.describe(code));
}

// The same error as a JSON body your API can send back.
const body = errors.toProblemDetails("ai.provider.out_of_credits");
console.log(JSON.stringify(body));

// An Error you can throw. Its .message is the code, so logs stay searchable.
// The sentence comes from describe().
// Starting from a raw failure, problemFor() classifies it and keeps the
// observed status — including when the matched code registers several.
const fromRaw = errors.problemFor({ status: 422 });
console.log(JSON.stringify(fromRaw));

const err = errors.create("config.missing", { field: "API_KEY" });
console.log(err.message);
console.log(errors.describe(err.code, err.params));
  1. Run node try.mjs. This is the real output from the code in this repository:
ai.provider.out_of_credits   Your provider account is out of credits. Add credits and try again.
http.rate_limited            Too many requests. Wait a moment and try again.
net.unreachable              Couldn't reach the server. Check your connection and try again.
internal.unknown             Something went wrong. Try again.
{"type":"ai.provider.out_of_credits","title":"Your provider account is out of credits. Add credits and try again.","status":402}
{"type":"internal.unknown","title":"Something went wrong. Try again.","status":422}
config.missing
A required setting is missing: API_KEY.

Each failure got one code and one sentence. The 429 went to corePack's http.rate_limited because corePack is listed first (see Which list of codes to start from). The teapot matched nothing, so it fell back to internal.unknown instead of showing a raw error. The JSON line is the standard error format for web APIs (RFC 9457, "Problem Details"), ready to send from a server.

The last two lines are an error you can throw. Its .message is the code (config.missing), not the sentence, so the same failure always logs the same searchable text. To show the sentence, call errors.describe(err.code, err.params).

To show the sentence in another language, see the next section.

Show errors in the user's language

describe takes your app's translation function as its third argument. It asks for the key errors.<code> (for example errors.http.rate_limited). If your translations have that key, the user sees it. If not, they see the English sentence from your list of codes, never a raw key. The code itself never changes, so your logs and API stay the same in every language.

The package ships Spanish (es) and Brazilian Portuguese (pt) for all 24 built-in codes, as plain i18next JSON files. Load them into the i18next you already use:

npm install @gainratio/errors i18next

Save this as languages.mjs (it is also examples/languages.mjs) and run node languages.mjs:

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

const errors = defineErrorsWith({}, corePack);

// Load the Spanish and Portuguese files that ship with the package into your
// own i18next. English needs no file: it comes from the list of codes.
await i18next.init({
  fallbackLng: "en",
  // describe() returns plain text, like its English. Escape it where you
  // render it, not here, or "/" comes back as "&#x2F;".
  interpolation: { escapeValue: false },
  resources: { es: { translation: es }, pt: { translation: pt } },
});

const code = errors.classify({ status: 429 }); // always "http.rate_limited"

for (const language of ["en", "es", "pt-BR", "fr"]) {
  const t = i18next.getFixedT(language);
  console.log(language.padEnd(6), errors.describe(code, {}, t));
}

// Values fill in the same way in every language.
const t = i18next.getFixedT("es");
console.log(
  errors.describe("config.missing", { field: "Settings / AI key" }, t),
);

// On the wire, only the title changes. The code and type never do.
const body = errors.toProblemDetails(
  code,
  {},
  { title: errors.describe(code, {}, t) },
);
console.log(JSON.stringify(body));

The real output:

en     Too many requests. Wait a moment and try again.
es     Demasiadas solicitudes. Espera un momento e inténtalo de nuevo.
pt-BR  Muitas solicitações. Aguarde um momento e tente de novo.
fr     Too many requests. Wait a moment and try again.
Falta un ajuste obligatorio: Settings / AI key.
{"type":"http.rate_limited","title":"Demasiadas solicitudes. Espera un momento e inténtalo de nuevo.","status":429}

i18next does the language matching. pt-BR (and pt-PT) get the Portuguese file, and a language with no file, like fr, gets English. Keep escapeValue: false: describe returns plain text, the same as its English, and you escape it where you render it. With i18next's default, a value like Settings / AI key comes back as Settings &#x2F; AI key. The package never picks a language on its own and never imports i18next. Any function shaped like t(key, params) works in its place. For your own codes, add errors.<code> keys to your own translation files, or set i18nKey on the entry to reuse a key you already have. The API guide has the details.

How it works

flowchart LR
    A["Raw failure<br/>(402, timeout, network down)"] --> B["classify()<br/>rules from YOUR catalog"]
    B --> C["One stable code<br/>ai.provider.out_of_credits"]
    C --> D["describe()<br/>a sentence, in your language"]
    C --> E["toProblemDetails()<br/>RFC 9457 JSON"]
Loading

You build one list of codes when your app starts. Each code has a category, an English sentence, and optional rules for spotting it, such as "HTTP status 402" or "the error message mentions a timeout". classify looks at the failure you pass in (its status, its name, and its message) and runs only the rules on your list, so it returns one code from that list. describe turns a code into a sentence, and toProblemDetails turns it into the JSON body. There is no hidden global list and no network or file access. The logic is about 260 lines of TypeScript.

Which list of codes to start from

The package ships four ready-made lists. They are optional. You can also write every code yourself.

List Codes Use it when
corePack 10 Almost always. Everyday codes for HTTP errors, network drops, timeouts, and bad config.
aiPack 9 Your app calls an AI model provider: no key, bad key, out of credits, rate limited.
bundlePack 5 Your app downloads and checks a file on the device, such as a model or a data set.
starterPack 18 Only for the older apps that already use it. New code should use corePack.

starterPack is kept frozen for earlier callers; new callers should pick corePack, aiPack, or bundlePack.

Lists combine: every code from every list you pass is registered. If two lists define the same code, you get a DuplicateCodeError when the app starts, never a silent winner. Order only matters when two different codes claim the same status. In the example above, corePack comes first, so a 429 becomes http.rate_limited and only the 402 reaches aiPack. If a registry only handles calls to an AI provider, put aiPack first and a 429 becomes ai.provider.rate_limited. The Architecture doc lists every status the two share.

aiPack has no catch-all code, so defineErrorsWith({}, aiPack) on its own throws and tells you to add corePack or pick one of your codes as the fallbackCode.

What it does not do

  • It does not log, retry, or report errors, and it does not send anything anywhere. Pair it with your logger or an error tracker.
  • It cannot guess a cause that only your app knows about. Add a code with a rule for it.
  • It does not escape messages for HTML. Escape them where you render them. Values you pass in become part of the message and the JSON body, so never pass a secret.
  • It is tested on Node only. It has no Node-only imports, so a bundler can ship it to a browser, but no browser test runs in CI.
  • It is pre-1.0. Code names are treated as a stable contract, but the API can still change between minor versions. See the CHANGELOG.

When to use something else

If you need Use
One or two error screens in a small app A few hand-written messages. This would be overkill.
Only HTTP status to text, in one language A small switch (status) helper
Collecting errors, alerts, and dashboards An error tracker such as Sentry. It pairs well with this: log the code this package gives you.
One set of error codes shared by your screens, logs, and API responses @gainratio/errors

Install

npm install @gainratio/errors    # or: pnpm add @gainratio/errors

It needs Node 22.13 or newer and has no dependencies. There is nothing to configure. The only setting is which code to return when nothing matches (see the API guide).

Develop

git clone https://github.com/gainratio/errors.git && cd errors
corepack enable && pnpm install
pnpm gate

pnpm gate runs lint, type checks, the tests with a 100% coverage bar, and the build. It is the same command CI runs and takes under 20 seconds. Start with Getting started for developers, which covers the Node version to use, a map of the code, and a walkthrough of a first change.

More detail

  • Architecture: how classify picks a code, the four lists, the JSON body's safety rules, the security model, and what the tests prove.
  • API guide: every export, with examples for your own codes, translations, custom fallbacks, and throwing a coded error.
  • Getting started for developers: from a fresh clone to a passing build and your first change.
  • Interactive architecture map: the same design as a clickable diagram.
  • Examples: try.mjs and languages.mjs (above), out-of-credits.mjs, and quickstart.mjs, which adds your own code and a Spanish translation.
  • SECURITY.md: the threat model and how to report a problem.
  • CONTRIBUTING.md: the rules for changes.
  • CHANGELOG: what changed in each version.

License

MIT. See LICENSE.

About

Turns any error your app hits into one stable code and one clear message, for developers tired of mismatched error text.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages