@nice-code/error
v0.58.0
Published
Readme
@nice-code/error
Docs: nicecode.io — guides, integrations, and the full API surface. Working with an AI assistant? Point it at nicecode.io/llms-error.txt (just this package) or nicecode.io/llms.txt (the whole stack) — the complete, current docs flattened into plain text.
Typed, serializable errors with domain hierarchies, pattern matching, and safe transport across API boundaries.
Install
bun add @nice-code/errorCore concepts
- Domain — a named group of related errors (
err_user_auth,err_payment) - Schema — maps error id strings to message, HTTP status, and optional typed context
- Error id — identifies what went wrong within a domain; multiple ids can be active on one error
- Context — structured data attached to an id (e.g.
{ username: string }) - Hydration — deserializing context from a JSON round-trip back to the original typed value
Defining an error domain
import { defineNiceError, err } from "@nice-code/error";
export const err_auth = defineNiceError({
domain: "err_auth",
schema: {
// No context — second arg to fromId is not accepted
account_locked: err({
message: "Account is locked",
httpStatusCode: 403,
}),
// Required context — second arg to fromId is required
invalid_credentials: err<{ username: string }>({
message: ({ username }) => `Invalid credentials for: ${username}`,
httpStatusCode: 401,
context: { required: true },
}),
// Optional context
rate_limited: err<{ retryAfter: number }>({
message: (ctx) => ctx ? `Retry after ${ctx.retryAfter}s` : "Rate limited",
httpStatusCode: 429,
context: {},
}),
},
});err() — schema entry helper
| Schema entry | fromId("id") | fromId("id", ctx) |
|---|---|---|
| err() / err({ message, httpStatusCode }) | ✓ | ✗ |
| err<C>({ context: {} }) | ✓ optional | ✓ optional |
| err<C>({ context: { required: true } }) | ✗ | ✓ required |
Custom serialization (non-JSON-safe context)
fs_error: err<{ cause: NodeJS.ErrnoException }>({
message: ({ cause }) => `FS error: ${cause.message}`,
context: {
required: true,
serialization: {
toJsonSerializable: ({ cause }) => ({ code: cause.code, message: cause.message }),
fromJsonSerializable: (obj) => ({ cause: Object.assign(new Error(obj.message), obj) }),
},
},
}),Creating errors
// Single id — context required per schema
const err1 = err_auth.fromId("invalid_credentials", { username: "alice" });
// Multi-id in one error
const err2 = err_auth.fromContext({
invalid_credentials: { username: "bob" },
rate_limited: { retryAfter: 30 },
});
// Chain additional ids after construction
const err3 = err_auth.fromId("account_locked").addId("rate_limited");Reading errors
// Check and narrow type
if (err1.hasId("invalid_credentials")) {
const { username } = err1.getContext("invalid_credentials"); // typed
}
// Check multiple ids at once
if (err2.hasOneOfIds(["invalid_credentials", "rate_limited"])) {
// ACTIVE_IDS narrowed to that subset
}
// Pattern match — calls first matching handler, returns its result
import { matchFirst } from "@nice-code/error";
const message = matchFirst(err1, {
invalid_credentials: ({ username }) => `Wrong password for ${username}`,
account_locked: () => "Account locked",
_: () => "Unknown auth error",
});Domain hierarchies
const err_app = defineNiceError({ domain: "err_app", schema: {} });
const err_auth = err_app.createChildDomain({
domain: "err_auth",
schema: { /* ... */ },
});
const err_auth_registration = err_auth.createChildDomain({
domain: "err_auth_registration",
schema: { /* ... */ },
});
// Ancestry checks
err_app.isParentOf(err_auth_registration); // true
err_auth.isParentOf(err_auth_registration); // true
err_auth_registration.isParentOf(err_auth); // false
// Type guard — exact domain match only
err_auth.isExact(someError); // true only for err_auth domain
err_auth.isThisOrChild(someError); // true for err_auth and all childrenSerialization & transport
// Serialize to a plain JSON object (safe to send over HTTP)
const json = error.toJsonObject();
const str = error.toJsonString();
const response = error.toHttpResponse(); // Response with correct HTTP status
// On the receiving side — reconstruct from anything
import { castNiceError } from "@nice-code/error";
const caught = castNiceError(unknownValue); // always returns NiceError
if (err_auth.isExact(caught)) {
const hydrated = err_auth.hydrate(caught); // deserializes context
const { username } = hydrated.getContext("invalid_credentials");
}castNiceError handles: NiceError instances, serialized JSON objects, native Error, error-like objects, nullish values, and primitives.
Anything that wasn't already a NiceError becomes a generic wrapper with error.isUnhandled === true — "an error we never accounted for". Domain-defined errors (including ones reconstructed from JSON) keep isUnhandled === false. isUnhandled survives transport.
castAndHydrate — cast + narrow + hydrate in one call
The idiomatic way to handle an unknown value arriving from a remote boundary when you have a specific domain in mind:
import { castAndHydrate } from "@nice-code/error";
const error = castAndHydrate(unknownValue, err_auth);
// → NiceErrorHydrated when it belongs to err_auth, otherwise the raw cast NiceError
if (err_auth.isExact(error)) {
const message = matchFirst(error, {
invalid_credentials: ({ username }) => `Wrong password for ${username}`,
account_locked: () => "Account locked",
});
}Error handling with handlers
Functional style (composable)
import { forDomain, forId, forIds } from "@nice-code/error";
const handled = error.handleWithSync([
forId(err_auth, "invalid_credentials", (h) => {
const { username } = h.getContext("invalid_credentials");
return res.status(401).json({ error: `Bad credentials for ${username}` });
}),
forIds(err_auth, ["account_locked", "rate_limited"], (h) => {
return res.status(h.httpStatusCode).json({ error: h.message });
}),
forDomain(err_payment, (h) => {
return res.status(500).json({ error: "Payment failed" });
}),
]);
// Async handlers
await error.handleWithAsync([
forDomain(err_payment, async (h) => {
await db.logFailure(h.message);
await notify(h.toJsonObject());
}),
]);Class-based style (reusable handler)
import { NiceErrorHandler } from "@nice-code/error";
const handler = new NiceErrorHandler()
.forId(err_auth, "invalid_credentials", (h) => "unauthorized")
.forDomain(err_payment, (h) => "payment_error")
.setDefaultHandler((e) => "unknown_error");
handler.handleErrorWithPromiseInspection(error);Utility types
import type { InferNiceError, InferNiceErrorHydrated } from "@nice-code/error";
type TAuthError = InferNiceError<typeof err_auth>;
// → NiceError<{ domain: "err_auth"; ... }, keyof schema>
type TAuthErrorHydrated = InferNiceErrorHydrated<typeof err_auth>;Packing for transport
Useful when errors must travel through systems that don't preserve custom properties (e.g. some RPC layers).
import { EErrorPackType } from "@nice-code/error";
error.pack(EErrorPackType.msg_pack); // embeds JSON in error.message
error.pack(EErrorPackType.cause_pack); // embeds JSON in error.cause
error.unpack(); // restore originalRunning code that can fail — niceTry
Turn a throwing function into a typed result whose failure branch is always a NiceError.
import { niceTry, niceTryAsync, type TNiceResult } from "@nice-code/error";
const r = niceTry(() => JSON.parse(input));
if (!r.ok) report(r.error); // r.error is a NiceError (isUnhandled === true for a raw throw)
else use(r.output);
const r2 = await niceTryAsync(() => fetchUser(id)); // sync or async fnTNiceResult<OUT, ERR extends NiceError = NiceError> is { ok: true; output } | { ok: false; error: ERR }. The catch runs through castNiceError, so declared domain errors pass through untouched and everything else becomes an isUnhandled wrapper.
Build results by hand with niceOk(output) / niceErr(error) — handy for functions that return a TNiceResult without going through niceTry:
import { niceOk, niceErr } from "@nice-code/error";
function parsePort(raw: string): TNiceResult<number> {
const n = Number(raw);
return Number.isInteger(n) ? niceOk(n) : niceErr(err_config.fromId("bad_port", { raw }));
}Observability
import { onNiceError, setNiceErrorLogger } from "@nice-code/error";
// Tap every NiceError at creation — pipe to Sentry / OTel / devtools.
const off = onNiceError((error) => report(error.toStructuredLog()));
// Route the library's internal diagnostics through your own logger (default: console).
setNiceErrorLogger({ warn: myLogger.warn, debug: myLogger.debug, error: myLogger.error });error.toStructuredLog() returns a flat, log-friendly object
({ domain, ids, message, httpStatusCode, isUnhandled, timeCreated, originError? }) — distinct from
toJsonObject() (the wire form).
Sending across postMessage / structuredClone
A NiceError is an Error subclass, so structuredClone / postMessage drop its custom fields.
Serialize before sending and cast on receipt — toJsonObject() is the single canonical wire form:
worker.postMessage(error.toJsonObject()); // sender
const error = castNiceError(event.data); // receiverNested cause chains (and AggregateError.errors) are serialized to a bounded depth, JSON-safe.
To pattern-match an error that arrived over the wire, hydrate first (castAndHydrate), then
matchFirst — a still-unhydrated context would otherwise throw.
Internal imports
The headline @nice-code/error surface is what most apps need. Lower-level helpers (wire-format
inspection, the err_cast_not_nice fallback domain, isNiceErrorObject, context-state types) live
behind an explicit subpath for libraries built on top of nice-error:
import { isNiceErrorObject, err_cast_not_nice } from "@nice-code/error/internal";