@frogfish/k2error
v2.0.1
Published
A simple error handling library for K2 applications.
Readme
@frogfish/k2error
Strict, RFC‑7807–compliant error contracts for Node/TypeScript. Explicit, grep‑friendly traces and a stable error taxonomy for consistent APIs.
Small, strict error library for building consistent APIs and services.
It centers around two ideas:
- A well-defined set of service error types with stable HTTP mappings.
- An explicit, source-typed trace string that you put in code and pass through layers, so you can grep across systems and find the exact throw site.
No magic. No auto-generation. You control the trace.
Why This Exists
When an error trickles down through multiple system layers (handlers → services → repositories → SDKs), the most useful anchor for debugging is a unique token that you can search for across all code and logs. Here, that token is a simple string you type into source right where the error originates. Because it is hard-coded, you can:
- Grep your entire monorepo and satellite services for the token and land on its origin instantly.
- Share the token in alerting, dashboards, and support tickets.
- Keep stack traces private while still getting a precise breadcrumb.
Auto-generated traces defeat this: they’re random, ephemeral, and not searchable in code. This library intentionally requires an explicit trace string.
Features
- ServiceError enum mapped to HTTP status codes.
K2Errorwith RFC 7807 Problem Details shape:{ type, title, status, detail, trace, chain }.- Explicit, caller-provided
tracestring; never auto-generated. - Helpers:
assert,assertNotNull,invariantfor concise validation.wrapto normalize unknown errors intoK2Errorwithout losing your trace.httpStatusto retrieve canonical HTTP code for aServiceError.isK2Errortype guard.
- ESM package with TypeScript declarations.
Install
npm i @frogfish/k2errorQuick Start
Create a human-meaningful, grep-friendly trace inline at the origin (use your password/ID generator, or a short mnemonic):
import { K2Error, ServiceError, assert } from "@frogfish/k2error";
export function createUser(input: { username?: string }) {
assert(input.username, "Username required", "t-user-create-usernamerequired-001");
// ... continue
}Wrap unknown errors with the same trace so it’s searchable end-to-end:
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");
}Mapping and Semantics
ServiceError values map to HTTP status codes. Key ones:
- BAD_REQUEST, VALIDATION_ERROR, INVALID_REQUEST → 400
- UNAUTHORIZED, INVALID_TOKEN, TOKEN_EXPIRED, AUTH_ERROR → 401
- FORBIDDEN, INSUFFICIENT_SCOPE → 403
- NOT_FOUND → 404
- 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:
ALREADY_EXISTSuses 409 (conflict).SERVICE_ERRORis an internal/server error (500). UseBAD_GATEWAY(502) for upstream gateway failures.- Use
UNAUTHORIZED(401) for authentication failures andFORBIDDEN(403) for authorization denials.
API
Imports
import {
K2Error,
ServiceError,
assert,
assertNotNull,
invariant,
wrap,
chain,
rethrow,
attempt,
attemptSync,
attemptResult,
httpStatus,
isK2Error,
} from "@frogfish/k2error";class K2Error
Constructor
new K2Error(error: ServiceError, errorDescription: string, trace: string, originalError?: unknown)Properties
type: string – RFC 7807 type URI identifying the error type.title: string – short, human-readable summary of the error type.status: number – canonical HTTP status based onerror.detail: string – human-readable description (safe for clients).trace: string – explicit token you typed in code.chain: array – semantic hops with timestamps representing error propagation.name: always"K2Error".cause?: the original error/value when provided.
Methods
toJSON()→ RFC 7807 problem+json:{ type, title, status, detail, trace, chain }(safe for clients, no stack).toDebugJSON()→ liketoJSON()but includesstackand normalizedcause(for protected logs only).
function assert
assert(condition: unknown, errorDescription: string, trace: string, error?: ServiceError): asserts conditionThrows K2Error(error ?? VALIDATION_ERROR, ...) if condition is falsy. Use for concise guards and TS narrowing.
function assertNotNull
assertNotNull<T>(value: T | null | undefined, errorDescription: string, trace: string, error?: ServiceError): asserts value is TThrows if value is null or undefined. Narrows to T on success.
function invariant
Alias of assert for stylistic preference.
function wrap
wrap(err: unknown, error?: ServiceError, trace: string, errorDescription?: string): K2ErrorNormalizes unknown errors to K2Error. If err is already a K2Error, it is returned as-is (keeps its original trace). Otherwise a new K2Error is created using your provided trace and optional description.
function chain
chain(err: unknown, trace: string, errorDescription?: string, error?: ServiceError, stage?: string): K2ErrorAdds a semantic hop to the error’s chain and returns a K2Error for rethrow. Optionally updates the surface error/code and error_description. The stage is a free-form label (e.g., "service:createUser", "repo:save").
function rethrow
rethrow(err: unknown, trace: string, errorDescription?: string, error?: ServiceError, stage?: string): neverConvenience wrapper around chain(...) that throws immediately.
function attempt (async)
attempt<T>(fn: () => Promise<T> | T, trace: string, errorDescription?: string, error?: ServiceError, stage?: string): Promise<T>Runs fn and, on failure, rethrows as K2Error using chain(...). Replace many boundary try/catch blocks with this helper.
Example:
const user = await attempt(
() => repo.save(input),
"t-user-create-dbinsert-002",
"Failed to persist user",
ServiceError.SERVICE_ERROR,
"repo:save"
);function attemptSync (sync)
attemptSync<T>(fn: () => T, trace: string, errorDescription?: string, error?: ServiceError, stage?: string): TSynchronous variant; useful for parsing/validation helpers.
Example:
const id = attemptSync(
() => parseId(raw),
"t-parse-id-001",
"Invalid ID",
ServiceError.VALIDATION_ERROR,
"util:parseId"
);function attemptResult (result-style)
attemptResult<T>(fn: () => Promise<T> | T, trace: string, errorDescription?: string, error?: ServiceError, stage?: string): Promise<{ ok: true; value: T } | { ok: false; error: K2Error }>Never throws; returns a discriminated union. Handy in controllers to avoid try/catch.
Example:
const r = await attemptResult(
() => http.get(url),
"t-fetch-001",
"Upstream request failed",
ServiceError.BAD_GATEWAY,
"sdk:get"
);
if (!r.ok) return res.status(r.error.status).type("application/problem+json").json(r.error.toJSON());
return res.json(r.value);function httpStatus
httpStatus(error: ServiceError): numberReturns the canonical HTTP status for a ServiceError.
function isK2Error
isK2Error(e: unknown): e is K2ErrorType guard for values shaped like K2Error.
Patterns
1) Keep trace inline at origin
assert(input.items?.length, "At least one item required", "t-order-create-010");Why: You can search this token in code and land exactly here.
2) Propagate the same trace through layers
const order = await attempt(
() => repository.save(input),
"t-order-create-db-011",
"Failed to save order",
ServiceError.SERVICE_ERROR,
"repo:save"
);Why: One token ties together logs from handler, service, and repository.
3) HTTP middleware (Express/Koa-style)
import { isK2Error } from "@frogfish/k2error";
function toResponse(err: unknown) {
if (isK2Error(err)) {
return {
status: err.status,
body: err.toJSON(),
};
}
// Unknown: map to generic 500 without stack
return {
status: 500,
body: {
type: "about:blank",
title: "Service Error",
status: 500,
detail: "An error occurred",
trace: "t-Unknown-000", // optional: replace with a specific handler-level trace
},
};
}When returning errors to HTTP clients, set Content-Type: application/problem+json for consistency with RFC 7807.
4) Authorization/Authentication
invariant(isAuthenticated, "Authentication required", "t-Auth-001", ServiceError.UNAUTHORIZED);
invariant(hasPermission, "Forbidden", "t-Auth-002", ServiceError.FORBIDDEN);5) Serialization and Logging
logger.warn({ trace: err.trace, type: err.type, chain: err.chain }, err.detail);
res.type("application/problem+json").status(err.status).json(err.toPublicJSON());Keep stacks out of client responses; use them only in protected logs.
Do’s and Don’ts
Do
- Hard-code a readable trace string near the throw site.
- Reuse the same trace as the error propagates.
- Use
assert/assertNotNull/invariantfor concise checks. - Choose the most specific
ServiceErrorto aid monitoring.
Don’t
- Don’t auto-generate traces — they’re not grep-friendly in code.
- Don’t leak stack traces to clients.
- Don’t conflate auth (401) and permission (403) errors.
Express/Koa Middleware Examples
Express-style handler without try/catch using attemptResult:
import express from "express";
import { attemptResult, ServiceError, isK2Error } from "@frogfish/k2error";
const app = express();
app.post("/users", async (req, res) => {
const r = await attemptResult(
() => userService.create(req.body),
"t-users-create-001",
"Failed to create user",
ServiceError.SERVICE_ERROR,
"svc:createUser"
);
if (!r.ok) return res.type("application/problem+json").status(r.error.status).json(r.error.toJSON());
res.status(201).json(r.value);
});
// Centralized error middleware — ensures only K2Error leaks
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
if (isK2Error(err)) return res.type("application/problem+json").status(err.status).json(err.toJSON());
// As a last resort, map unknowns to a safe response
return res.type("application/problem+json").status(500).json({ type: "about:blank", title: "Service Error", status: 500, detail: "An error occurred", trace: "t-unknown-000" });
});Koa-style example with attempt replacing try/catch:
import Koa from "koa";
import Router from "@koa/router";
import bodyParser from "koa-bodyparser";
import { attempt, isK2Error } from "@frogfish/k2error";
const app = new Koa();
const router = new Router();
router.post("/orders", async (ctx) => {
ctx.body = await attempt(
() => orderService.create(ctx.request.body),
"t-orders-create-001",
"Failed to create order",
undefined,
"svc:createOrder"
);
ctx.status = 201;
});
app.use(async (ctx, next) => {
try { await next(); }
catch (err) {
if (isK2Error(err)) {
ctx.status = err.status;
ctx.type = "application/problem+json";
ctx.body = err.toJSON();
} else {
ctx.status = 500;
ctx.type = "application/problem+json";
ctx.body = { type: "about:blank", title: "Service Error", status: 500, detail: "An error occurred", trace: "t-unknown-000" };
}
}
});
app.use(bodyParser());
app.use(router.routes());
app.use(router.allowedMethods());ESLint Rule: Enforce Inline Trace Literals
To ensure traces remain inline literals (not variables), add a no-restricted-syntax rule. This example forbids non-literal third args to assert, assertNotNull, invariant, wrap, chain, rethrow, attempt, attemptSync, attemptResult:
{
"rules": {
"no-restricted-syntax": [
"error",
{
"selector": "CallExpression[callee.name=/^(assert|assertNotNull|invariant|wrap|chain|rethrow|attempt|attemptSync|attemptResult)$/] > :matches(Identifier,MemberExpression,CallExpression,TemplateLiteral,ArrayExpression,ObjectExpression):nth-child(3)",
"message": "Trace must be an inline string literal."
},
{
"selector": "CallExpression[callee.name=/^(assert|assertNotNull|invariant|wrap|chain|rethrow|attempt|attemptSync|attemptResult)$/] Literal:nth-child(3)[regex(pattern, '.*', { flags: 'i' })]",
"message": ""
}
]
}
}If your ESLint doesn’t support complex selectors, a simpler alternative is a custom rule or a code review check. The intent: third argument must be a plain string literal.
TypeScript and Module Format
- ESM package with
"type": "module". - Ships
.d.tsand.jsunderdist. - Works with modern Node runtimes and bundlers. If using CommonJS, import via dynamic
import()or transpile.
License
GPL-3.0-only. See LICENSE.
Trace Tutorial
This library treats the trace as a deliberate, human-placed breadcrumb. Here’s a practical primer on how and why to use it.
Why traces
- Precise origin: A static token lets you jump to the exact throw site by grepping the repo or using code search tools (e.g., ripgrep, Sourcegraph, OpenGrok, livegrep, GitHub code search).
- Stable across layers: Reuse the same token to stitch logs from handler → service → repository → external SDK.
- Low-noise in logs: Short, opaque strings avoid leaking internals while still enabling targeted debugging.
How to create traces
- Use a password generator to produce a 20-character, lowercase alphanumeric string (a–z, 0–9). No special characters. Example:
q8x1t4e0m6r9b1p7d2c3. - Optionally prefix with a short mnemonic for readability/context, still keeping it grep-friendly. Example:
t-user-create-0q3x9b1l6k2v4y7m5c8. - Inline the trace literal directly at the assert/throw/wrap site. Do not store it in a variable. Do not auto-generate in code.
Step-by-step pattern
- Validate early with an inline assert literal:
assert(input.username, "Username required", "t-user-create-0q3x9b1l6k2v4y7m5c8");- Wrap unknown errors using the same literal:
try {
await repo.save(user);
} catch (err) {
throw wrap(err, ServiceError.SERVICE_ERROR, "t-user-create-0q3x9b1l6k2v4y7m5c8", "Failed to save user");
}- Log and return consistently:
logger.error({ trace: err.trace, type: err.type }, err.detail);
res.type("application/problem+json").status(err.status).json(err.toJSON());Naming conventions
- Keep tokens short and lowercase; avoid spaces/specials.
- Prefer stable tokens per decision point; do not reuse across unrelated code paths.
- Consider a light prefix for grouping (e.g.,
t-auth-...,t-order-...).
Anti-patterns
- Auto-generating traces in code (not searchable in source, changes every run).
- Overloading a single token for many unrelated failures.
- Leaking stack traces to clients (use
toPublicJSON()for responses). - For richer diagnostics in protected logs, use
toDebugJSON().
