@polygonlabs/verror
v1.0.1
Published
TypeScript-first, browser-friendly VError-inspired error handling library
Keywords
Readme
@polygonlabs/verror
Structured error handling for TypeScript — cause chains, accumulated messages, and typed
info fields that survive serialisation.
Zero runtime dependencies. Pure ESM. Works in Node.js and browsers.
Why not plain Error?
new Error('query failed') loses the context that caused it. You can attach a cause with
new Error('query failed', { cause: dbErr }), but the cause message disappears when you
log err.message, and JSON.stringify(err) produces {}.
VError solves both:
import { VError } from '@polygonlabs/verror';
const dbErr = new Error('connection refused');
const err = new VError('query failed', { cause: dbErr });
err.message; // 'query failed: connection refused'
err.shortMessage; // 'query failed'
JSON.stringify(err); // { name, message, shortMessage, cause: { ... }, info: {} }The full cause chain accumulates into message at construction time. shortMessage gives
you back the message you actually passed, without the appended chain.
Classes
VError
The base class. Wraps any Error as a cause and appends its full accumulated message.
const root = new Error('ECONNREFUSED');
const err = new VError('upstream timed out', { cause: root });
// err.message === 'upstream timed out: ECONNREFUSED'Native Error.cause chains are walked automatically. If the cause itself has a
.cause (ES2022 native cause), VError accumulates the whole chain — not just the
immediate cause message. This matters because third-party libraries and Node.js built-ins
increasingly set .cause directly:
const root = new Error('ECONNREFUSED');
const native = new Error('fetch failed', { cause: root }); // native ES2022 cause
const err = new VError('could not load config', { cause: native });
err.message; // 'could not load config: fetch failed: ECONNREFUSED'
// ^^^^^^^^^^^^^^^^^^^^^^^^^^
// full native chain accumulatedserializeError and toJSON also follow native cause chains, so the full chain is
preserved when serialising errors you did not create.
Structured context goes in info. Unlike the message, info fields are machine-readable
and aggregate up the chain via VError.info():
const err1 = new VError('db error', { info: { table: 'users' } });
const err2 = new VError('request failed', { cause: err1, info: { requestId: 'abc' } });
VError.info(err2); // { table: 'users', requestId: 'abc' }
// Closer errors win on key conflicts.WError (Wrapped Error)
Like VError but intentionally does not append the cause's message. Use it when you
want to report a high-level outcome without the noise of the full cause chain in message:
const dbErr = new VError('query failed: connection refused');
const appErr = new WError('could not load user', { cause: dbErr });
appErr.message; // 'could not load user' — cause message not appended
appErr.toString(); // 'WError: could not load user; caused by VError: query failed: ...'The cause is still traversable — it just stays out of message. Use WError at service
boundaries where the cause is an internal detail that shouldn't leak into user-facing
strings.
MultiError
Wraps multiple concurrent failures:
import { MultiError, errorFromList } from '@polygonlabs/verror';
const results = await Promise.allSettled(tasks);
const errors = results.filter(r => r.status === 'rejected').map(r => r.reason);
const err = errorFromList(errors); // null | Error | MultiErrorerrorFromList returns null for empty, the single error for one, and a MultiError for
many. errorForEach iterates either uniformly.
HTTP errors
HTTPError base class and 17 concrete subclasses, each with a typed statusCode:
import { NotFound, BadRequest, Forbidden } from '@polygonlabs/verror';
throw new NotFound('user not found', { cause: dbErr, info: { userId } });
// err.statusCode === 404
// err.toJSON() includes statusCodeAll subclasses: BadRequest (400), NotAuthenticated (401), PaymentError (402),
Forbidden (403), NotFound (404), MethodNotAllowed (405), NotAcceptable (406),
Timeout (408), Conflict (409), Gone (410), LengthRequired (411),
Unprocessable (422), TooManyRequests (429), GeneralError (500),
NotImplemented (501), BadGateway (502), Unavailable (503).
Serialisation
JSON.stringify on a plain Error produces {} — the message and cause are lost.
VError.toJSON() is called automatically by JSON.stringify and produces:
{
"name": "VError",
"message": "query failed: connection refused",
"shortMessage": "query failed",
"cause": { "name": "Error", "message": "connection refused", ... },
"info": {}
}To serialise an error you didn't create (e.g., from a catch block), use serializeError:
import { serializeError } from '@polygonlabs/verror';
try {
await fetch(url);
} catch (err) {
logger.error({ err: serializeError(err) }, 'fetch failed');
}serializeError handles any value: returns undefined for non-Errors, delegates to
toJSON() for VErrors, and produces the same JSON shape for plain Errors and native
Error.cause chains.
Static helpers
All available as standalone imports or as static methods on VError:
import { cause, info, fullStack, findCauseByName, findCauseByType } from '@polygonlabs/verror';| Helper | Purpose |
|--------|---------|
| cause(err) | Immediate cause, or null |
| info(err) | Merged info from the full chain |
| fullStack(err) | Stack trace with all chained causes appended |
| findCauseByName(err, name) | First cause whose .name matches |
| findCauseByType(err, Type) | First cause that is instanceof Type |
| hasCauseWithName(err, name) | Boolean shorthand for findCauseByName |
| hasCauseWithType(err, Type) | Boolean shorthand for findCauseByType |
| errorFromList(errors) | null / single error / MultiError |
| errorForEach(err, fn) | Iterate errors, unwrapping MultiError |
findCauseByName is useful even when you control the code. Once errors cross a
serialisation boundary, or when multiple copies of this package exist in a dependency
tree, instanceof checks fail. Name-based lookup works regardless.
Subclassing
Define the error name at the class level with override readonly name = '...' as const.
String literals survive minification; new.target.name does not:
class DatabaseError extends VError {
override readonly name = 'DatabaseError' as const;
}
class QueryTimeoutError extends DatabaseError {
override readonly name = 'QueryTimeoutError' as const;
}Unnamed subclasses inherit the parent's name — useful for internal specialisation without a new name at the error boundary:
class DatabaseError extends VError {
override readonly name = 'DatabaseError' as const;
constructor(message: string, options?: VErrorOptions) {
super(message, { ...options, info: { ...options?.info, layer: 'db' } });
}
}Differences from the original verror
This package is inspired by verror and
@openagenda/verror but diverges in several
places. If you are migrating from either, here is what changed.
Constructor API
The original constructor puts options or cause first, message last, and supports printf-style format strings as additional arguments:
// original verror — multiple overloads
new VError(message)
new VError(options, message)
new VError(cause, message)
new VError(cause, 'failed to %s %d items', 'update', 42)This package uses a single consistent signature — message first, options second:
// @polygonlabs/verror
new VError(message)
new VError(message, { cause, info })No sprintf. Messages are plain strings. Printf-style formatting mixes presentation into the error layer, produces hard-to-test messages, and makes the TypeScript types awkward. Format at the call site if you need interpolation.
Name is a class-level declaration
The original allowed setting name per instance via the options object:
new VError({ name: 'DatabaseError' }, 'query failed') // original onlyThis package does not support per-instance names. name is a property of the class,
not the instance — set it with override readonly name:
class DatabaseError extends VError {
override readonly name = 'DatabaseError' as const;
}This makes the error type visible in TypeScript, survives minification (string literals are not mangled), and is consistent with how the rest of the JavaScript ecosystem defines error names.
WError requires a cause
In the original, WError can be constructed without a cause. Here, cause is required
in the options — a WError with no cause is just a VError, so the constraint is
enforced at the type level.
Native Error.cause chain accumulation
The original predates ES2022 Error.cause. When wrapping a native error that itself
has a .cause, the original sees only the immediate cause message. This package walks
the full native chain:
const root = new Error('ECONNREFUSED');
const native = new Error('upstream failed', { cause: root }); // ES2022 native cause
const err = new VError('request failed', { cause: native });
err.message; // 'request failed: upstream failed: ECONNREFUSED'
// ^^^^^^^^^^^^ walked by this package
// ^^^^^^^^^^^^^^^ only this in original verrorWError still suppresses the entire cause chain from its own message, as expected.
Serialisation
The original has no built-in serialisation support — JSON.stringify(vErr) produces
{} because Error properties are non-enumerable. This package implements toJSON()
on all error classes and provides serializeError() for errors you did not create.
The full cause chain is serialised recursively, including plain Error and native
Error.cause links.
What was removed
sprintf/ printf-style formatting — use template literals insteadnamein constructor options — useoverride readonly nameon the classstrictmode — the option existed in some forks; not present heremeta— appeared in some forks as a duplicate ofinfo; not present here
What was added
serializeError()andtoJSON()— full cause chain serialisation- Native
Error.causechain accumulation — walks ES2022 cause chains - HTTP error classes —
HTTPErrorand 17 typed subclasses - Pure ESM, browser-compatible — no
require, no Node.js built-ins, zero runtime dependencies
