@ballistix.digital/exception-mapper
v0.5.0
Published
A NestJS module that maps any thrown value to a coded BaseException and to the ExceptionDto envelope, for HTTP filters and task processors.
Keywords
Readme
@ballistix.digital/exception-mapper
A NestJS module that maps any thrown value to a coded BaseException and to
the ExceptionDto envelope, for HTTP filters and task processors. A mapper
turns one kind of thrown value into an exception, and the registry holds
the mappers in order. The exception classes, the codes and the envelope live
in @ballistix.digital/exception-types.
This file is the consumer guide. The pages that describe how the package is built live in the Ballistix wiki.
Install
npm install @ballistix.digital/exception-mapper @ballistix.digital/exception-typesThe package needs Node 22.12 or later. Install these peer dependencies in the application.
| Peer | Range |
| --- | --- |
| @ballistix.digital/exception-types | the release each published manifest pins |
| @nestjs/common | ^11 |
| class-validator | ^0.15.1 |
| typeorm | >=0.3.0 <2.0.0 |
| reflect-metadata | ^0.2.2 |
The application throws the exception classes of the types package, and this
package maps to the same classes. A publish of exception-mapper pins the
exception-types version of the same build.
Register the module
Register the module once, at the root of the application. It is global, so a
service in any feature module injects ExceptionMapperRegistry without an
import.
import { Module } from '@nestjs/common';
import { ExceptionModule } from '@ballistix.digital/exception-mapper';
@Module({
imports: [ExceptionModule.forRoot()],
})
export class AppModule {}forRootAsync takes the options from a factory, so a ConfigService can
decide them:
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ExceptionModule } from '@ballistix.digital/exception-mapper';
@Module({
imports: [
ExceptionModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
exposeErrorDetails: config.get('EXPOSE_ERROR_DETAILS') === 'true',
}),
}),
],
})
export class AppModule {}The raw driver message of a QueryFailedError can carry SQL text and
constraint names. With exposeErrorDetails on, the database mapper adds that
message as a second entry of errors, and the title stays the generic
message. CAUTION: If an untrusted client reads the response, keep
exposeErrorDetails off. The option is off by default.
From a thrown value to the envelope
The registry asks each mapper in one order, and the first match wins.
flowchart TD
thrown["a thrown value"] --> base{"BaseExceptionMapper<br/>supports?"}
base -- yes --> same["the same BaseException"]
base -- no --> validation{"ValidationExceptionMapper<br/>supports?"}
validation -- yes --> failed["ValidationFailedException<br/>400"]
validation -- no --> database{"DatabaseExceptionMapper<br/>supports?"}
database -- yes --> dbError["DatabaseErrorException<br/>400 for SQLSTATE class 22, 23, P0<br/>500 for the rest"]
database -- no --> generic["GenericExceptionMapper<br/>GenericErrorException<br/>the status of the value, else 500"]
same --> dtoMapper["ExceptionDtoMapper"]
failed --> dtoMapper
dbError --> dtoMapper
generic --> dtoMapper
dtoMapper --> envelope["the ExceptionDto envelope"]
envelope --> retry{"status >= 500?"}
retry -- yes --> again["a task processor repeats the attempt"]
retry -- no --> stop["a task processor fails the task"]Look at the last branch: the status the mappers give is also the retry rule a task processor reads.
The database mapper reads the first two characters of the Postgres SQLSTATE,
its class. Class 22 is a data exception, such as a malformed uuid. Class 23 is
an integrity constraint violation, such as a unique or foreign key breach.
Class P0 is a RAISE EXCEPTION from PL/pgSQL. All three mean the statement
itself was wrong, which is the fault of the request, so they give 400. A
deadlock or a connection failure gives 500.
ValidationExceptionMapper matches the ValidationException that
convertAndValidate throws, and gives one detail for each field and rule that
failed. The args of a detail come from the decorator's explicit context
when it sets one, else from the rule's own constraint values, read from the
class-validator metadata of the validated class. So @Max(24) on months
gives { inputField: 'months', rule: 'MAX', args: { max: 24 } } with no
change to the DTO.
| Rule | args |
| --- | --- |
| min, max, minLength, maxLength, arrayMinSize, arrayMaxSize, minDate, maxDate | { min } or { max } |
| isLength, isByteLength | { min, max } |
| matches | { pattern, modifiers }, the source and flags of the RegExp |
| isIn, isNotIn, isEnum | { values } |
| isDivisibleBy | { divisor } |
| contains, notContains | { seed } |
| equals, notEquals | { comparison } |
| any other rule with constraint values | { constraints: [...] }, positional |
| a rule without constraint values (isString, isNotEmpty) | no args |
The values are JSON-safe: a RegExp becomes its source and flags, a Date its
ISO string, and a function or class object is left out. An error without a
target keeps the contexts only. GenericExceptionMapper is the fallback the registry uses when no mapper in
the list supports the value, so map() never returns undefined.
Write your HTTP filter
The package ships no @Catch() filter. Write one in the application:
import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common';
import { ExceptionMapperRegistry } from '@ballistix.digital/exception-mapper';
import { Response } from 'express';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
constructor(private readonly registry: ExceptionMapperRegistry) {}
public catch(exception: unknown, host: ArgumentsHost): void {
const response = host.switchToHttp().getResponse<Response>();
const envelope = this.registry.toExceptionDto(exception);
response.status(envelope.status).json(envelope);
}
}The filter stays in the application, so a breakpoint on the raw thrown value sits in application code. What the filter logs, and what request context it adds, is the decision of the application. See ADR 0002.
Register it as the global filter:
import { APP_FILTER } from '@nestjs/core';
@Module({
imports: [ExceptionModule.forRoot()],
providers: [{ provide: APP_FILTER, useClass: HttpExceptionFilter }],
})
export class AppModule {}Use it in a task processor
A task processor has no response to write. It stores the envelope on the row, and the status decides whether another attempt can succeed:
@Injectable()
export class ReportTaskProcessor {
constructor(
private readonly registry: ExceptionMapperRegistry,
private readonly repository: Repository<ReportTask>,
) {}
public async run(task: ReportTask): Promise<void> {
try {
await this.build(task);
} catch (error) {
const envelope = this.registry.toExceptionDto(error);
// A 5xx is a server fault, so the attempt can be repeated. A 4xx is the
// input's fault and repeats with the same result.
task.processingError = envelope;
task.status = envelope.status >= 500 ? TaskStatus.PENDING : TaskStatus.FAILED;
await this.repository.save(task);
}
}
}The retry rule belongs to the application. This package only says which status the thrown value earns.
Test with the registry
The registry is a plain class. A unit test builds it by hand, with no NestJS bootstrap:
const registry = new ExceptionMapperRegistry(
new BaseExceptionMapper(),
new ValidationExceptionMapper(),
new DatabaseExceptionMapper({ exposeErrorDetails: false }),
new GenericExceptionMapper(),
new ExceptionDtoMapper(),
);
expect(registry.toExceptionDto(new Error('boom'))).toMatchObject({
code: ExceptionCodeEnum.GENERIC_ERROR,
status: 500,
});DatabaseExceptionMapper is the only mapper with a constructor argument. Pass
{} when the test does not care about the driver message.
API reference
Everything below comes from the package root.
| Export | What it does |
| --- | --- |
| ExceptionModule.forRoot(options?) | Registers the module with options that are already known |
| ExceptionModule.forRootAsync(options) | The same, with imports, inject and a useFactory |
| ExceptionModuleOptions | { exposeErrorDetails?: boolean } |
| ExceptionModuleAsyncOptions | { imports?, inject?, useFactory } |
| EXCEPTION_MODULE_OPTIONS | The injection token of the resolved options |
| ExceptionMapperRegistry | Holds the mappers in order. map(error) gives the exception, toExceptionDto(error) gives the envelope |
| ExceptionDtoMapper | map(exception) turns one BaseException into the envelope |
| ExceptionMapper<TError> | The interface a mapper implements: supports(error) and map(error) |
| BaseExceptionMapper | Maps a BaseException to itself |
| ValidationExceptionMapper | Maps a ValidationException to ValidationFailedException |
| DatabaseExceptionMapper | Maps a TypeORM QueryFailedError to DatabaseErrorException |
| GenericExceptionMapper | Maps anything else to GenericErrorException |
Guarantees
toExceptionDto()never throws. A detail that fails its own validation travels raw into the envelope instead of replacing the error the caller handles.- First match wins. The order of the mappers is a constructor argument, not a search.
- The generic mapper is the fallback for a value no mapper in the list
supports, so
map()always returns aBaseException. - A validation rule with no
ValidationRuleEnummember becomesUNKNOWN, so a new class-validator rule cannot break the mapping. - The same thrown value gives the same envelope on every path. An HTTP response and a stored task error read alike.
- The package ships no filter, no logger and no HTTP dependency.
