@ballistix.digital/exception-types
v0.5.0
Published
The exception classes, the ExceptionDto envelope, the detail DTOs and the codes Ballistix NestJS applications throw and return.
Keywords
Readme
@ballistix.digital/exception-types
The exception classes, the ExceptionDto envelope, the detail DTOs and the
codes a Ballistix NestJS application throws and returns. The package holds
data and no behaviour: no module, no filter and no provider.
@ballistix.digital/exception-mapper turns a
thrown value into one of these exceptions and into the envelope.
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-typesThe package needs Node 22.12 or later. Install these peer dependencies in the application.
| Peer | Range | Required |
| --- | --- | --- |
| class-validator | ^0.15.1 | yes |
| class-transformer | ^0.5.1 | yes |
| lodash | ^4.17.21 | yes |
| luxon | ^3.7.2 | yes |
| reflect-metadata | ^0.2.2 | yes |
| @nestjs/common | ^11 | types only, for the . entry |
| @nestjs/swagger | ^11 | optional: the . entry uses its decorators when it is installed |
Load reflect-metadata once, before any decorated class is imported.
A browser bundle imports the ./react entry and needs neither Nest package:
import { ExceptionDto, ExceptionCodeEnum } from '@ballistix.digital/exception-types/react';The envelope
ExceptionDto is the shape an HTTP response body or a logged task failure
carries. Every field is optional, so a partial envelope from an older service
still validates.
| Field | Type | What it holds |
| --- | --- | --- |
| type | string | A URI that identifies the kind of problem. |
| title | string | The human-readable message of the exception. |
| status | number | The HTTP status the exception carries. |
| code | ExceptionCodeEnum | The code that names the kind of failure. |
| errors | object[] | The details: one entry per thing that went wrong, in the shape of the detail DTO of the code. |
The codes
Every code has one message template, one detail DTO and at least one exception
class. BaseException fills the template from the first detail, so a
placeholder such as {entity} reads the entity field of errors[0].
| Code | Status | Exception class | Detail DTO | Message template |
| --- | --- | --- | --- | --- |
| RESOURCE_NOT_FOUND | 404 | ResourceNotFoundException | ResourceNotFoundDetailDto | Could not find {entity} with {searchField} {searchValue} |
| REFERENCED_RESOURCE_NOT_FOUND | 400 | ReferencedResourceNotFoundException | ReferencedResourceNotFoundDetailDto | Field {requestField} references {entity} with {searchField} {searchValue}, which does not exist |
| VALIDATION_FAILED | 400 | ValidationFailedException | ValidationErrorDetailDto | Validation failed |
| DATABASE_ERROR | 500, or the 4xx the caller passes | DatabaseErrorException | GenericErrorDetailDto | {message} |
| ACTION_NOT_PERMITTED | 403 | ActionNotPermittedException | ActionNotPermittedDetailDto | Not allowed to {action} a {entity} |
| ACCESS_DENIED | 403 | AccessDeniedException | GenericErrorDetailDto | {message} |
| GENERIC_ERROR | 500, or 400 from GenericBadRequestException | GenericErrorException, GenericBadRequestException | GenericErrorDetailDto | {message} |
| FILE_INVALID | 400 | FileInvalidException | NoDetailDto | The uploaded file cannot be read |
| XLSX_CONTENT_NOT_FOUND | 400 | XlsxContentNotFoundException | XlsxContentNotFoundDetailDto | Could not find {entity} with {searchField} {searchValue} |
| XLSX_REFERENCED_RESOURCE_NOT_FOUND | 400 | XlsxReferencedResourceNotFoundException | XlsxReferencedResourceNotFoundDetailDto | Could not find {entity} with {searchField} {searchValue} |
| XLSX_VALIDATION_FAILED | 400 | XlsxValidationFailedException | XlsxValidationErrorDetailDto | Validation of {inputField} failed ({rule}) |
| CONFLICT | 409 | ConflictException | ConflictDetailDto | Declared {field} {declared} conflicts with {found} |
| PRECONDITION_FAILED | 409 | PreconditionFailedException | PreconditionFailedDetailDto | Precondition {condition} is not met |
Three groups need a word:
The two 403 codes.
ACTION_NOT_PERMITTEDnames the verb and the entity of an authorization denial, so a client can show "you cannot update a valuation".ACCESS_DENIEDis the 403 with no such pair, such as a business-state guard, and carries a free-text message.The three
XLSX_*codes. Each detail carries anXlsxLocationDto: the sheet, plus the 1-based row and column that the importer knows. The numbers match what Excel itself shows.NoDetailDto.FILE_INVALIDpoints at nothing inside the file, so itserrorsstays an empty array.PRECONDITION_FAILED. The current state of a resource does not allow the action: a completed valuation that cannot change, a file that is not uploaded yet, a status transition the state machine rejects. The detail is{ condition, args? }. The condition is a SCREAMING_SNAKE key that each owner types in its own enum, andargscarries the values of that condition under names the condition defines. A client translates on the code and the condition together, and fills the translation fromargs. The message stays the template, so no throw site writes a sentence.throw new PreconditionFailedException('INVALID_STATE_TRANSITION', { entity: 'Valuation', field: 'status', from: 'OPEN', to: 'COMPLETED', });
Throw an exception
Every exception class extends BaseException, which extends Error. The
constructor arguments are the fields of the detail. The class fills in the
code, the status and the message.
import { ResourceNotFoundException } from '@ballistix.digital/exception-types';
const valuation = await this.repository.findOneBy({ id });
if (!valuation) {
// message: "Could not find Valuation with id 42", status 404
throw new ResourceNotFoundException('Valuation', id);
}import { ValidationFailedException, ValidationRuleEnum } from '@ballistix.digital/exception-types';
// message: "Validation failed", status 400, one detail per rejected field
throw new ValidationFailedException([
{ inputField: 'name', rule: ValidationRuleEnum.IS_STRING },
{ inputField: 'age', rule: ValidationRuleEnum.MIN, args: { min: 18 } },
]);An exception exposes message, status, code and errors. errors holds
one instance of the detail DTO per detail. A detail that fails its own
validation is kept raw instead of dropped, so a constructor never throws.
Build your own exception
A new exception class extends BaseException and passes the detail DTO, the
code, the status and the details to super:
import { BaseException, ExceptionCodeEnum, GenericErrorDetailDto } from '@ballistix.digital/exception-types';
export class UpstreamUnavailableException extends BaseException<GenericErrorDetailDto> {
constructor(service: string) {
super(GenericErrorDetailDto, ExceptionCodeEnum.GENERIC_ERROR, 503, [{ message: `${service} is unavailable` }]);
}
}The code must be a member of ExceptionCodeEnum, because BaseException
reads its message template from EXCEPTION_MESSAGES. That map is typed
Record<ExceptionCodeEnum, string>, so a code without a template does not
compile, and only a change to this package adds one. Reuse GENERIC_ERROR
while the new code is not there yet.
Rebuild an exception from an envelope
An exception that crossed a boundary as plain data comes back as an envelope:
the processing error a task row stores, or the envelope a worker thread posts.
When the code on this side needs an exception again, a subclass passes the
envelope to BaseException. The title becomes the message. The type, the
status, the code and the details stay as the envelope carries them, so a
mapper registry maps the exception the way it mapped the original, and a
filter renders the same envelope again.
import { BaseException, ExceptionDto } from '@ballistix.digital/exception-types';
export class WorkerException extends BaseException<object> {
constructor(envelope: ExceptionDto) {
super(envelope);
}
}
// message "Validation failed", status 400, code VALIDATION_FAILED, the details as stored
throw new WorkerException(task.processingError);This path never fills the message template of the code, because the title is
already the filled message. A field the envelope lacks gets a default: an
empty message, status 500, GENERIC_ERROR and no details. A code this version
of the package does not know also becomes GENERIC_ERROR, so an envelope from
a newer producer still rebuilds.
Validation rules
ValidationRuleEnum has one member per class-validator rule constant, named
after the constant (IS_STRING, MIN, ARRAY_NOT_EMPTY), plus UNKNOWN. A
detail of a validation failure names the rule that rejected the value.
validationRuleByName maps the decorator name that class-validator reports to
the member:
import { validationRuleByName, ValidationRuleEnum } from '@ballistix.digital/exception-types';
validationRuleByName['isString']; // ValidationRuleEnum.IS_STRING
validationRuleByName['nope'] ?? ValidationRuleEnum.UNKNOWN; // the fallbackThe lookup is exact and has no entry for UNKNOWN. Fall back to
ValidationRuleEnum.UNKNOWN for a name it does not carry, so a custom
decorator degrades to a generic detail instead of throwing.
convertAndValidate
convertAndValidate(cls, plain) turns a plain object, or an array of them,
into instances of cls and validates them with class-validator. When a rule
fails, it throws a ValidationException. getValidationErrors() gives back
the raw class-validator errors.
import { convertAndValidate, ExceptionDto, ValidationException } from '@ballistix.digital/exception-types';
try {
const envelope = convertAndValidate<ExceptionDto, unknown>(ExceptionDto, await response.json());
} catch (error) {
if (error instanceof ValidationException) {
console.warn(error.getValidationErrors());
}
}Three behaviours are worth knowing:
- Luxon. Every
DateTimein the input, at any depth, becomes its ISO string before conversion. class-transformer otherwise rebuilds aDateTimewith its default constructor and sets every date to the current time. The function changes the input object in place. - Undecorated DTOs.
forbidUnknownValuesis off, so a DTO that carries no class-validator decorator passes instead of failing. - Extraneous values.
excludeExtraneousValuesis the third argument and defaults to on, so only@Expose()d properties survive the conversion.
The two entries
| Entry | Import | Decorators | Needs @nestjs/* |
| --- | --- | --- | --- |
| . | @ballistix.digital/exception-types | the real @nestjs/swagger decorators when the package is installed, the shim otherwise | when installed |
| ./react | @ballistix.digital/exception-types/react | a shim with the same call signatures and no runtime effect | no |
Both entries compile from the same src/, so the classes, the fields and the
codes are identical. Only the decorators differ. A Nest application imports
the . entry and gets OpenAPI schemas for the DTOs. A browser bundle imports
./react and gets the same shapes with nothing under @nestjs/ in its graph.
See ADR 0003.
