A tiny, type-safe decorator that translates errors thrown by a method — or by every method of a class — through an ordered list of rules. Unmatched errors are rethrown as-is, so domain exceptions and genuine bugs pass through untouched.
when/to receives the exact instance type
of its from class, inferred from a variadic tuple. No per-rule casts.A → B mapping
followed by B → C turns a thrown A into a C. Opt out per annotation with
{ pipeline: false }.experimentalDecorators
and TC39 Stage-3 decorators. One import, detected at runtime.pnpm add error-mapper-decorator
import { MapErrors } from "error-mapper-decorator";
type User = { id: string; name: string };
class NotFoundError extends Error {}
class ValidationError extends Error {
constructor(public readonly field: string) {
super(`invalid ${field}`);
}
}
class HttpError extends Error {
constructor(
public readonly status: number,
options?: ErrorOptions,
) {
super(`http ${status}`, options);
}
}
class UserService {
@MapErrors(
// `error` is inferred as ValidationError — `.field` is available, no cast.
// Pass the caught error as `cause` to keep its stack and message.
{ from: ValidationError, when: (error) => error.field === "email", to: (error) => new HttpError(422, { cause: error }) },
{ from: NotFoundError, to: (error) => new HttpError(404, { cause: error }) },
)
async getUser(id: string): Promise<User> {
// ...
}
}
When getUser throws a ValidationError on the email field it is re-thrown as
HttpError(422); a NotFoundError becomes HttpError(404); anything else
propagates unchanged.
Rules are applied top to bottom. Put a subclass rule before its superclass rule — otherwise the superclass rule maps the error first, and its result (a different type) no longer matches the more specific rule below:
@MapErrors(
{ from: SpecificError, to: () => new HttpError(409) }, // applied first
{ from: BaseError, to: () => new HttpError(500) }, // general fallback
)
pipeline)By default the rules form a pipeline: each rule whose from matches the
current error transforms it and passes the result to the next rule, so mappings
compose (A → B → C):
@MapErrors(
{ from: SqlError, to: (e) => new RepositoryError({ cause: e }) },
{ from: RepositoryError, to: (e) => new ServiceError({ cause: e }) },
)
// a thrown SqlError becomes RepositoryError, then ServiceError — and because
// each `to` forwards `cause`, the full chain is preserved (Service → Repo → Sql).
Each rule fires at most once per call, so there are no loops. Because a to
normally produces an error in a different layer than the inputs (e.g. domain →
HTTP), unrelated rules simply don't match and you get the same result as a single
mapping — the chaining only kicks in when a mapped error is itself the from of
a later rule.
Pass { pipeline: false } to stop at the first matching rule instead:
@MapErrors(
{ pipeline: false },
{ from: ParseError, to: (e) => new RequestError({ cause: e }) },
{ from: RequestError, to: () => new HttpError(400) }, // NOT applied to the line above
)
when)A rule only fires when its optional when predicate returns true. If when
returns false, evaluation continues to the next rule (and falls through to a
plain rethrow if nothing else matches).
cause)to returns a brand-new error, so the original's stack and message are lost
unless you forward them. Pass the caught error as the standard cause option:
{ from: QueryError, to: (error) => new RepositoryError("lookup failed", { cause: error }) }
Your error class just needs to forward the options to super:
class RepositoryError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
}
}
The original error is then available as mappedError.cause for logging and
debugging.
Apply @MapErrors to a class to wrap every instance method with the same
rules, instead of annotating each one:
@MapErrors(
{ from: ValidationError, to: (e) => new HttpError(422, { cause: e }) },
{ from: NotFoundError, to: (e) => new HttpError(404, { cause: e }) },
)
class UserService {
async getUser(id: string): Promise<User> {
/* ... */
}
async createUser(input: NewUser): Promise<User> {
/* ... */
}
}
Pass an options object first to narrow the set. include/exclude are validated
at decoration time — an unknown method name throws:
@MapErrors({ exclude: ["healthCheck"] }, { from: DbError, to: (e) => new ServiceError({ cause: e }) })
class OrdersService {
placeOrder() {
/* wrapped */
}
healthCheck() {
/* left alone */
}
}
Applying the options form to a single method is a type error. See Limitations for exactly what the class form does and doesn't wrap.
When a method is reached by more than one annotation — its own method-level
@MapErrors, its class's, and any annotated ancestor's — every applicable rule
list is merged, ordered by specificity:
method-level > child class > parent class
Nothing is dropped: subclassing only ever adds mappings, and the merged list
is evaluated as one pipeline. On a conflict (two levels map the same error type)
the more specific level wins because it is applied first. The most-specific
annotation also decides the pipeline mode for the whole merged list.
Because the merged list runs as a single forward pass, a chain that spans levels
only composes when the producing rule is at least as specific as the
consuming one — a method-level A → B feeds a class-level B → C, but not
the reverse. (Chains within a single annotation are unaffected: you control the
order.)
The effective list is resolved from the runtime receiver, so a subclass's class-level rules also apply to methods it inherits:
@MapErrors({ from: DbError, to: (e) => new RepoError({ cause: e }) })
class BaseRepo {
find() {
/* throws DbError */
}
}
@MapErrors({ from: TimeoutError, to: (e) => new RepoError({ cause: e }) })
class UserRepo extends BaseRepo {}
// new UserRepo().find() maps BOTH DbError (from BaseRepo) and TimeoutError
// (from UserRepo) — even though find() is inherited, not overridden.
The class form wraps own instance methods on the prototype. It deliberately leaves everything else alone — know what falls outside that net:
@MapErrors, or route through an instance method.descriptor.value is a function) are; accessor descriptors are skipped.handler = async () => {}) are assigned
per-instance in the constructor, so they aren't on the prototype at decoration
time and are not wrapped — use a normal method, or a method-level
@MapErrors.@MapErrors()
re-wraps the override so inherited rules apply again).Matching is instanceof-only: a rule fires when the thrown value is an
instance of its from class. Thrown non-Error values (strings, plain objects)
and errors discriminated only by a field such as Node's err.code won't match —
normalize them into error classes upstream, or wrap the call with a rule whose
from they satisfy.
Runnable, type-checked examples live in
examples/
and are executed in CI under both decorator standards, so they never drift from
the code:
basic-method.ts — method-level mapping, sync + async, cause.whole-class.ts — class decoration, exclude, inheritance.pipeline-chaining.ts — pipeline chaining and { pipeline: false }.Run them with pnpm examples.
The decorator works with either TypeScript decorator implementation — pick the one your project uses:
TC39 Stage-3 (default in TypeScript 5+, no flag needed):
{ "compilerOptions": { "target": "ES2022" } }
Legacy (experimentalDecorators):
{ "compilerOptions": { "experimentalDecorators": true } }
No code change is required to switch — the same @MapErrors(...) compiles and
runs under both.
The full generated API reference (TypeDoc) is published at https://mquesada02.github.io/error-mapper-decorator/.
MapErrors(...rules): MapErrorsDecoratorMapErrors(options, ...rules): MapErrorsClassDecoratorA decorator factory. With no leading options it decorates a method or a
class (wrapping every instance method). A leading options object may carry
pipeline (valid on either) and include/exclude (class only — a type
error on a method). Each rule is a plain object:
| Field | Type | Required | Description |
|---|---|---|---|
from |
new (...args) => E |
yes | Error class to match (via instanceof). |
when |
(error: E) => boolean |
no | Extra guard; rule only fires when this returns true. |
to |
(error: E) => Error |
yes | Maps the caught error to the error to re-throw. Pass the original as cause to keep its stack. |
options:
| Field | Type | Description |
|---|---|---|
pipeline |
boolean |
Thread each rule's output into the next (A → B → C). Default true; false stops at the first match. |
include |
readonly string[] |
Class form only — apply this class's rules to these methods only (default: all). |
exclude |
readonly string[] |
Class form only — methods this class's rules should skip. |
Also exported: the ErrorRule, ErrorClass, MapErrorsOptions,
MapErrorsDecorator, and MapErrorsClassDecorator types.
tois synchronous by design. It must produce the replacement error immediately so a synchronous method can stay synchronous. For async enrichment (e.g. a remote lookup), do it in a separate layer rather than in a rule.
MIT