@shirudo/base-error
v10.0.0
Published
A cross-environment base error class for TypeScript applications, designed for seamless use across Node.js, browsers, and edge runtimes.
Maintainers
Readme
@shirudo/base-error
Structured errors for TypeScript: the full error for your logs, a safe answer for your clients.
@shirudo/base-error gives each error a stable code, a category and a
retryable flag. Your logs get the whole error, with every cause. A client gets
only the answer that you registered for that code.
The package runs on Node.js, Bun, Deno, in browsers and on edge runtimes such as Cloudflare Workers. It has no runtime dependencies.
Why
A plain Error carries only text. In a service, that causes three problems:
- You cannot handle it safely. Code that tests
err.message.includes("not found")breaks when somebody changes the text. - Your logs lose it.
JSON.stringify(new Error("failed"))gives{}. A logger that serializes an error this way loses the message, the stack and every cause. - Your clients can see too much. An error handler that sends the error to the client also sends its stack and its database message. That is a known vulnerability (CWE-209).
This library removes the three problems:
| Problem | With @shirudo/base-error |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| Handling by text | You match on a typed code. The compiler tells you when a code has no handler. |
| Errors lost in logs | toLogObject() writes the message, the stack, every cause and the details. You can mask fields that hold personal data. |
| Leaks to clients | The core cannot serialize an error for a client. A separate pipeline builds the response only from the codes that you registered. |
How it works
An error that you throw takes one of two paths:
┌─▶ toLogObject() ──────────────────▶ your logger
│ message, stack, causes, details
throw StructuredError ┤
│
└─▶ project() ─▶ toProblem() ───────▶ HTTP response
registered public codes only (RFC 9457)Each path has its own import:
| Import | Use it to |
| ---------------------------------- | ------------------------------------------------------------------------ |
| @shirudo/base-error | Throw, match, inspect and log errors. |
| @shirudo/base-error/public-error | Build the response for a client. This is the only path to client output. |
Installation
npm install @shirudo/base-errorThe package is also on JSR: deno add jsr:@shirudo/base-error, or
npx jsr add @shirudo/base-error.
Quick start
1. Define an error
import { StructuredError } from "@shirudo/base-error";
export class UserNotFoundError extends StructuredError<
"USER_NOT_FOUND",
"NOT_FOUND"
> {
constructor(userId: string, cause?: unknown) {
super({
code: "USER_NOT_FOUND",
category: "NOT_FOUND",
retryable: false,
message: `No user ${userId} in the primary database`, // for the log
details: { userId },
cause,
});
}
}The message is technical text for the log. The library never sends it to a
client, but the default error handler of a framework can. Fastify 5 sends it,
and Express 5 sends it when NODE_ENV is not production. Install your own
error handler, as step 4 shows.
2. Log and handle it
import { matchErrorCode } from "@shirudo/base-error";
const error = new UserNotFoundError("123", dbError);
logger.error(error.toLogObject());
// { name, message, timestamp, timestampIso, stack,
// cause: { name: "Error", message: "connection reset by peer", ... },
// code: "USER_NOT_FOUND", category: "NOT_FOUND", retryable: false,
// details: { userId: "123" } }
const user = matchErrorCode(error, {
USER_NOT_FOUND: () => null, // a missing user is "no result" here
_: (other) => {
throw other;
},
}); // nulltoLogObject() copies the error into plain data. JSON.stringify(error) gives
the same result, so a logger that serializes the error gets the full content.
If a caught value has the type unknown, toStructuredError(value) turns it
into a StructuredError.
3. Mask personal data in the log (optional)
error.redact(["userId"]);
// toLogObject() and JSON.stringify(error) now write details.userId as "[REDACTED]".The policy stays on the error. It also applies when another error logs this
one as its cause. redactAllow works the other way: it masks every data field
that you did not list. console.log(error) prints the error itself, not the
log object, so it shows the raw value. Log logObjectOf(error) instead.
4. Answer the client
import {
definePublicErrors,
project,
toProblem,
} from "@shirudo/base-error/public-error";
// Register once, at startup. Only a registered code reaches a client.
const publicErrors = definePublicErrors({
fallback: { publicCode: "internal_error", status: 500 },
}).registerByCode("USER_NOT_FOUND", {
publicCode: "user_not_found",
status: 404,
});
// In the error handler of your HTTP server:
const view = project(publicErrors, error);
const { status, headers, body } = toProblem(publicErrors, view);
// status: 404
// headers: { "content-type": "application/problem+json" }
// body: { "status": 404, "code": "user_not_found" }The technical message, the stack and the details do not cross. An error that
you did not register gets the fallback: 500 with the code internal_error.
The optional localize stage adds a message for the end user in the language
of the request.
Features
Define errors
StructuredErrorcarries a typedcode, acategory, aretryableflag anddetails.defineErrorsdefines many codes in one place, with typed factories and metadata per code. With a prefix, a catalog derives dotted codes such asbilling.card_declined, and its factories need no brackets.StructuredAggregateErrorcollects many failures in one error, for example fromPromise.allSettled.ValidationErrorcollects field issues. The issue format is compatible with Standard Schema, andpublicIssues()gives the subset that is safe for a client.
Handle errors
matchErrorCodedispatches oncode. The compiler makes sure that every code has a handler.defineErrorClassSetdoes the same for a set of error classes.matchThrownmatches any caught value by class or by guard.- Guards such as
isError,isStructuredError,hasErrorCodeandisRetryablenarrow a caught value without a cast. They also work for an error from a worker or from a second copy of the package. - The cause-chain helpers (
getRootCause,findInCauseChain,isChainRetryableand more) follow thecauselinks, also into the members of anAggregateError.
Log errors
toLogObject()writes the complete error as plain JSON data: the message, the stack, every cause, the members of an aggregate, and the details.logObjectOf(value)writes the log form of any thrown value: an error of this library, a nativeError, or a value that is not an error.- The subpaths
@shirudo/base-error/pinoand@shirudo/base-error/sentrykeep the policy of an error in a pino line and in a Sentry event. They add no dependency. redact,redactAllow,partialMaskandredactWithmask personal data in the log.StructuredError.fromJSONrebuilds a typed error from a log or from a worker message.structuredClonedrops the members of anAggregateError, but this round trip keeps them.AppErrors.fromJSONrestores an error of a catalog, with its type and its redaction floor.
Answer clients
- The public-error pipeline turns an error into a safe view (
project), adds a localized message (localize) and builds an RFC 9457 body (toProblem). - One registration per public code controls the status, the type, the message and the details that a client can see.
Safe in a catch block
- No function on the logging, rendering or traversal path throws. A getter that throws, a revoked Proxy or a cyclic cause chain costs only the affected value. The log shows a marker there, or leaves the field out.
- The work stays bounded for any value that code throws, also for very deep or very wide error graphs.
When to use it
The library fits a service or an API that has more than a few error cases,
writes structured logs, or answers HTTP clients. For a small script, a plain
Error is enough.
Documentation
The full guide is in
docs/guide/.
To read it locally, run pnpm docs:dev.
Introduction
Core
- BaseError
- StructuredError
- Error catalog (
defineErrors) - Validation errors
- Matching errors (
matchErrorCode) - Cause chains
- Type guards and assertions
Boundaries
- Public error pipeline
- Observability and logging (with PII redaction and
fromJSON) - Logging with pino
- Reporting to Sentry
Reference
Agent Skill
The package ships an agent skill for AI coding assistants in
skills/base-error/, and the npm and JSR packages
include it. Copy the folder into your project's skill directory, such as
.claude/skills/ for Claude Code or .agents/skills/ for other agents. It
teaches the assistant the catalogs, the guards and matchers, the log path with
redaction, the setups for pino and Sentry, and the public-error pipeline. It
also maps the APIs of older majors onto the APIs of this major version.
Requirements
- Node.js 24 or a later version. Bun, Deno, modern browsers and edge runtimes work too.
- TypeScript 5.0 or a later version, with
strictmode, for full type safety. libES2020 or a later version.- For an ES module: module resolution
node16,nodenextorbundler. - For a CommonJS module:
module: nodenextwith TypeScript 5.8 or later, ormodule: node20with TypeScript 5.9 or later.module: node16refuses the package (TS1479).module: commonjs(resolutionnode10) works with TypeScript 5 and 6. TypeScript 7 removednode10. - The package ships one ES module build with type declarations.
require()loads it on Node.js 24, soimportandrequireload the same classes.
Upgrade
Version 10 ships one ES module build. A TypeScript project that compiles to
CommonJS with module: node16 must switch to module: nodenext or
module: node20. Read the
migration guide before you upgrade from 9.x.
Version 9 changes the log output and some APIs. For example, it removes
buildLogObject() in favor of buildOwnLogFields(). Read the
migration guide before you upgrade from 8.x.
