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).
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
-
Make an empty project and install the package:
mkdir try-errors && cd try-errors && npm init -y >/dev/null && npm install @gainratio/errors
-
Save this as
try.mjs(it is alsoexamples/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));- 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.
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 i18nextSave 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 "/".
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 / 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.
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"]
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.
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.
- 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.
| 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 |
npm install @gainratio/errors # or: pnpm add @gainratio/errorsIt 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).
git clone https://github.com/gainratio/errors.git && cd errors
corepack enable && pnpm install
pnpm gatepnpm 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.
- Architecture: how
classifypicks 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.mjsandlanguages.mjs(above),out-of-credits.mjs, andquickstart.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.
MIT. See LICENSE.