Strict, RFC 7807–compliant error contracts for Node.js & TypeScript.
Explicit, grep‑friendly traces and a stable error taxonomy for consistent APIs and services.
@frogfish/k2error is a small, dependency‑free error library for building consistent back‑end APIs.
It favors explicitness over magic and human‑placed traces over auto‑generated IDs.
When an error flows through multiple layers (HTTP handler → service → repository → SDK), stack traces alone are noisy and ephemeral.
This library introduces a deliberate, static trace string that you place directly in source code at the origin of an error.
Because the trace is hard‑coded, you can:
- Grep your entire codebase and satellite services for the trace
- Use it in logs, dashboards, alerts, and support tickets
- Keep stack traces private while still getting precise breadcrumbs
Auto‑generated IDs defeat this purpose — they are random, ephemeral, and not searchable in code.
This library intentionally forbids auto‑generation.
-
Stable service error taxonomy
A well‑definedServiceErrorenum mapped to canonical HTTP status codes. -
Explicit trace strings
You supply a trace string at the throw site and reuse it as the error propagates. -
No magic
No auto IDs, no logging, no framework coupling, no dependencies.
ServiceErrorenum mapped to HTTP status codesK2Errorwith RFC 7807 Problem Details shape
{ type, title, status, detail, trace, chain }- Explicit, caller‑provided trace string (never auto‑generated)
- Semantic error chaining across layers
- Assertion helpers with TypeScript narrowing
- ESM‑only, strict TypeScript, ships
.d.ts - Zero runtime dependencies
npm install @frogfish/k2errorUse a short, human‑meaningful, grep‑friendly literal:
import { assert } from "@frogfish/k2error";
export function createUser(input: { username?: string }) {
assert(
input.username,
"Username required",
"t-user-create-username-required-001"
);
}import { wrap, ServiceError } from "@frogfish/k2error";
try {
await db.insert(user);
} catch (err) {
throw wrap(
err,
ServiceError.SERVICE_ERROR,
"t-user-create-dbinsert-002",
"Failed to persist user"
);
}One trace → many layers → one searchable breadcrumb.
| ServiceError | HTTP |
|---|---|
| BAD_REQUEST, VALIDATION_ERROR, INVALID_REQUEST | 400 |
| PAYMENT_REQUIRED | 402 |
| UNAUTHORIZED, INVALID_TOKEN, TOKEN_EXPIRED, AUTH_ERROR | 401 |
| FORBIDDEN, INSUFFICIENT_SCOPE | 403 |
| NOT_FOUND | 404 |
| UNSUPPORTED_METHOD | 405 |
| CONFLICT, ALREADY_EXISTS | 409 |
| TOO_MANY_REQUESTS | 429 |
| SYSTEM_ERROR, CONFIGURATION_ERROR, SERVICE_ERROR | 500 |
| NOT_IMPLEMENTED | 501 |
| BAD_GATEWAY | 502 |
| SERVICE_UNAVAILABLE | 503 |
| GATEWAY_TIMEOUT | 504 |
Notes
- Use
UNAUTHORIZED(401) for authentication failures - Use
FORBIDDEN(403) for authorization failures - Use
BAD_GATEWAY(502) for upstream dependencies ALREADY_EXISTSmaps to409(conflict)
import {
K2Error,
ServiceError,
assert,
assertNotNull,
invariant,
wrap,
withSensitive,
chain,
rethrow,
attempt,
attemptSync,
attemptResult,
httpStatus,
isK2Error,
serialize,
PROBLEM_JSON,
} from "@frogfish/k2error";interface ErrorChainItem {
error: ServiceError;
error_description: string;
stage?: string;
at: number;
}
interface ProblemDetails {
type: string;
title: string;
status: number;
detail: string;
trace?: string;
chain: ReadonlyArray<ErrorChainItem>;
}
interface ProblemDetailsDebug extends ProblemDetails {
cause?: unknown;
stack?: string;
}new K2Error(
error: ServiceError,
errorDescription?: string,
trace?: string,
originalError?: unknown
)error– semantic error typecode– HTTP status codeerror_description– detailed error descriptiontrace?– explicit trace tokenchain– semantic propagation hopscause?– original errorsensitive?– internal-only payload (non-enumerable when set viasetSensitive()/withSensitive()) for rich debugging data that must not be sent to clientsname– error name ("K2Error")kind– stable discriminator ("K2Error")
toJSON()→ RFC 7807 payload (safe for clients)toPublicJSON()→ same as toJSON()toDebugJSON()→ includes stack & cause (logs only)
assert(condition, errorDescription?, trace?, error?)
assertNotNull(value, errorDescription?, trace?, error?)
invariant(condition, errorDescription?, trace?, error?)All throw K2Error and narrow types in TS.
wrap(err, error?, trace?, errorDescription?)
withSensitive(err, value)
chain(err, trace?, errorDescription?, error?, stage?)
rethrow(err, trace?, errorDescription?, error?, stage?)chain() mutates the error intentionally to preserve a single causal identity.
Sometimes you need to attach rich context (such as upstream error codes, request payload fragments, or a DB pipeline) for logging and diagnostics, but must never leak this information to clients. The RFC7807 response remains stable and sanitized.
Why: Stacks and causes can be private; traces are static and searchable; sensitive lets you attach structured diagnostics at the origin without accidentally serializing them.
How: The sensitive property is intentionally non-enumerable when set through err.setSensitive(value) or withSensitive(err, value). It will not appear in JSON.stringify(err) or in toPublicJSON() / toJSON().
Boundary pattern: Log toDebugJSON() plus err.sensitive, but respond using toPublicJSON().
Example (HTTP boundary):
import { isK2Error, PROBLEM_JSON, wrap } from "@frogfish/k2error";
app.use((err, req, res, next) => {
const k2 = isK2Error(err) ? err : wrap(err);
// Internal logs: include debug + sensitive payload if present
req.log?.error?.({ ...k2.toDebugJSON(), sensitive: (k2 as any).sensitive }, "request failed");
// Public response: RFC 7807 only (no stack, no cause, no sensitive)
res.status(k2.code).type(PROBLEM_JSON).send(k2.toPublicJSON());
});Example (MongoDB aggregation):
import { chain, ServiceError } from "@frogfish/k2error";
try {
const rows = await collection.aggregate(pipeline).toArray();
return rows;
} catch (err) {
// Attach rich diagnostics for logs only
throw chain(err, "sys_mdb_ag", "Aggregation failed", ServiceError.SYSTEM_ERROR, "repo.aggregate")
.setSensitive({
op: "aggregate",
collection: collection.collectionName,
pipeline,
mongo: {
name: (err as any)?.name,
message: (err as any)?.message,
code: (err as any)?.code,
codeName: (err as any)?.codeName,
},
});
}Keep
error_description/chainfree of secrets because they are public.
sensitiveis for structured diagnostics and may contain request fragments; treat it like secrets.
await attempt(fn, trace?, errorDescription?, error?, stage?)
attemptSync(fn, trace?, errorDescription?, error?, stage?)
await attemptResult(fn, trace?, errorDescription?, error?, stage?)attemptResult never throws and returns { ok, value | error }.
httpStatus(error: ServiceError) → number
serialize(err, opts?) → ProblemDetails | ProblemDetailsDebugWhere opts: { debug?: boolean; trace?: string }
Always return RFC 7807 payloads:
if (isK2Error(err)) {
res
.status(err.code)
.type(PROBLEM_JSON)
.json(err.toJSON());
}- Hard‑code the trace literal at the throw site
- Reuse the same trace as the error propagates
- Keep traces short and lowercase
- Optionally prefix with a mnemonic (
t-user-create-…)
- Auto‑generate traces
- Store traces in variables
- Leak stack traces to clients
- Reuse the same trace for unrelated failures
GPL‑3.0‑only. See LICENSE.