error-mapper-decorator
    Preparing search index...

    error-mapper-decorator

    error-mapper-decorator

    CI npm version npm downloads OpenSSF Scorecard docs

    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.

    • Type-safe rules — each rule's when/to receives the exact instance type of its from class, inferred from a variadic tuple. No per-rule casts.
    • Composable — by default the rules run as a pipeline, so an A → B mapping followed by B → C turns a thrown A into a C. Opt out per annotation with { pipeline: false }.
    • Sync and async — the wrapper preserves the method's return type. Sync methods stay sync; async rejections are mapped on the promise.
    • Both decorator standards — works under legacy experimentalDecorators and TC39 Stage-3 decorators. One import, detected at runtime.
    • Zero dependencies.
    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
    )

    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
    )

    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).

    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:

    • Static methods are not wrapped — they live on the constructor, not the prototype. Use a method-level @MapErrors, or route through an instance method.
    • Accessors (getters/setters) are not wrapped — only data-property methods (whose descriptor.value is a function) are; accessor descriptors are skipped.
    • Arrow-function class fields (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.
    • Overrides in an un-annotated subclass shadow the base's wrapped method and are no longer mapped. Annotate the subclass (even an empty @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:

    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/.

    A 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.

    to is 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