NestJS course Β· Module 6: Error Handling and Monitoring
Custom Exceptions - special situations
In this lesson5
The filter from the previous lesson stands ready, but does it have anything worth intercepting? In the service sits throw new Error('tribute not found'). The filter receives an object it knows only one thing about - that it is an error. To recognise what happened it would have to read the message text. And the moment somebody fixes a typo in that sentence, the handling stops working.
A Roman clerk did not describe a crisis with a sentence. He entered a case code, a severity and whether it can be dealt with on the spot - and only from those fields was it clear who should take it. A well-built exception works the same way.
What to inherit from
The first decision tends to mislead, because NestJS has its own HttpException class. It is tempting to use it - after all it already knows about HTTP codes.
That is a trap. HttpException belongs to the transport layer, while your exceptions describe the domain: a missing tribute, an exceeded legion limit, unpaid wages. If a service threw HttpException, it would know HTTP exists - and then you could not use it in a batch job or a queue consumer, where there is no HTTP at all. Remember the rule from the previous lesson: it is the filter that translates internal exceptions into HTTP. The service only has to say what happened.
So we inherit from Error - JavaScript's built-in class. Beware of two plausible-sounding names that do not exist: NestException and RuntimeException. The latter comes from Java and C#; JavaScript has no such thing.
The base class
The common ancestor of all our exceptions looks like this:
1export abstract class LegionaryException extends Error {
2 constructor(
3 message: string,
4 public readonly code: string,
5 public readonly severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL',
6 public readonly recoverable: boolean = false,
7 public readonly context: Record<string, unknown> = {},
8 ) {
9 super(message);
10 this.name = this.constructor.name;
11
12 Error.captureStackTrace(this, this.constructor);
13 }
14
15 toJSON() {
16 return {
17 name: this.name,
18 code: this.code,
19 severity: this.severity,
20 recoverable: this.recoverable,
21 context: this.context,
22 };
23 }
24}The class is abstract, because nobody should throw it directly - it serves only as a common ancestor. Thanks to that, a filter needs only @Catch(LegionaryException) to catch every domain exception at once.
Let's walk the four fields, because each answers a different question:
code is a fixed case identifier, for instance 'TRIBUTE_NOT_FOUND'. It is what you recognise an error by in code and in logs - never the message text, which gets translated and reworded.
severity says how serious the situation is. Four levels from least to most critical: LOW for minor problems, MEDIUM for problems needing attention, HIGH for a serious threat, CRITICAL for a critical failure. From this field an alerting system decides whether to write a log entry or wake somebody with a phone call.
recoverable answers whether the error can be fixed automatically. A momentary loss of the database connection is recoverable - worth retrying. A missing tribute is not: retrying achieves nothing, because a second later it still will not be there. This field steers the retry mechanism so it does not endlessly attempt what is lost from the start.
context carries accompanying data - a tribute id, a legion name, whatever helps make sense of the situation when reading a log. The Record<string, unknown> type is an ordinary object with arbitrary keys.
The toJSON() method returns the exception in a shape ready to be written to a log. Note what it does not contain: the stack trace. That is deliberate - a stack helps while debugging, but in logs gathered from production it would take nine tenths of the space.
captureStackTrace - a clean trail
One line deserves a separate explanation:
1Error.captureStackTrace(this, this.constructor);Without it the stack trace would begin inside the exception's constructor - a place that does not interest you, because you wrote it once and it works. The second argument says: skip this constructor and everything above it, start the trail at the place where the exception was really thrown.
It is a small thing that saves a lot of squinting when reading logs. The line itself encrypts nothing, deletes nothing and sends nothing - it only sets where the trail starts.
A concrete exception
With an ancestor in place, individual exceptions are short:
1export class TributeNotFoundException extends LegionaryException {
2 constructor(tributeId: string) {
3 super(
4 `Tribute ${tributeId} not found`,
5 'TRIBUTE_NOT_FOUND',
6 'MEDIUM',
7 false,
8 { tributeId },
9 );
10 }
11}The order is always the same: the class declaration, extends LegionaryException, the constructor taking the data needed to describe the case, and inside it super(...) filling the ancestor's fields.
Note what you gain at the point of use. Instead of throw new Error('tribute not found ' + id) you write throw new TributeNotFoundException(id) - shorter, and the filter recognises the type with instanceof without reading any message. The identifier lands in context, so the log will say which tribute it was.
I recommend this as a rule: one exception per domain situation, with its code and severity fixed once, in the constructor. Then the service has no decision left to make - you throw the right class and move on.
Summary
Crises carry case codes, not sentences:
- domain exceptions inherit from
Error- JavaScript's built-in class, - not from
HttpException, which belongs to the transport layer and would make the service know about HTTP;NestExceptionandRuntimeExceptiondo not exist at all, - it is the filter that translates an exception into an HTTP code - the exception only says what happened,
- the base class is abstract, so
@Catch(LegionaryException)catches the whole family, codeis a fixed case identifier - recognise errors by it, never by the message text,severityfrom least to most critical: LOW β MEDIUM β HIGH β CRITICAL,recoverablesays whether the error can be fixed automatically - it steers retries,contextcarries data useful when reading a log,toJSON()prepares an entry without the stack trace,Error.captureStackTrace(this, this.constructor)starts the trail at the throw site, skipping the constructor - it encrypts nothing and deletes nothing,- a concrete exception: the class,
extendsthe base one, a constructor,super(...)with the code and severity.
In the next lesson we will follow those exceptions further - how to record them in the chronicle of failures so that anything can be read back from it. For now remember: an exception is a case form with a code, a severity and a note - not a sentence somebody will reword one day.
Code for this lesson: src/exceptions/custom-exceptions.ts
1// Custom Exceptions - Special Roman Situations
2// Custom exception hierarchy for the Imperium
3import { HttpException, HttpStatus } from '@nestjs/common';
4
5// ===========================================
6// 1. Base Imperium exception
7// ===========================================
8
9export abstract class LegionaryException extends HttpException {
10 public readonly code: string;
11 public readonly severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
12 public readonly recoverable: boolean;
13
14 constructor(
15 code: string,
16 message: string,
17 status: HttpStatus,
18 severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' = 'MEDIUM',
19 recoverable: boolean = true,
20 ) {
21 super(
22 {
23 code,
24 message,
25 severity,
26 recoverable,
27 timestamp: new Date().toISOString(),
28 },
29 status,
30 );
31 this.code = code;
32 this.severity = severity;
33 this.recoverable = recoverable;
34 }
35}
36
37// ===========================================
38// 2. Specific exceptions
39// ===========================================
40
41// Legionary not found
42export class LegionaryNotFoundException extends LegionaryException {
43 constructor(legionaryId: string) {
44 super(
45 'LEGIONARY_NOT_FOUND',
46 'Legionary ' + legionaryId + ' does not exist in the registry!',
47 HttpStatus.NOT_FOUND,
48 'LOW',
49 false,
50 );
51 }
52}
53
54// Insufficient permissions
55export class InsufficientRankException extends LegionaryException {
56 constructor(requiredRank: string, currentRank: string) {
57 super(
58 'INSUFFICIENT_RANK',
59 'Required rank: ' + requiredRank + ', current: ' + currentRank,
60 HttpStatus.FORBIDDEN,
61 'MEDIUM',
62 false,
63 );
64 }
65}
66
67// Tribute depleted
68export class TributeDepletedException extends LegionaryException {
69 constructor(province: string) {
70 super(
71 'TRIBUTE_DEPLETED',
72 'Province ' + province + ' has no more tributes!',
73 HttpStatus.CONFLICT,
74 'HIGH',
75 true,
76 );
77 }
78}
79
80// Battle error
81export class BattleException extends LegionaryException {
82 constructor(message: string) {
83 super(
84 'BATTLE_ERROR',
85 message,
86 HttpStatus.INTERNAL_SERVER_ERROR,
87 'CRITICAL',
88 false,
89 );
90 }
91}
92
93// ===========================================
94// 3. Example of use
95// ===========================================
96
97function findLegionary(id: string) {
98 const legionaries = ['LEG-001', 'LEG-002', 'LEG-003'];
99
100 if (!legionaries.includes(id)) {
101 throw new LegionaryNotFoundException(id);
102 }
103
104 return { id, name: 'Marcus Aurelius', rank: 'centurion' };
105}
106
107console.log('=== Custom Exceptions ===');
108console.log('LegionaryException - base exception class');
109console.log('Fields: code, severity, recoverable, timestamp');
110console.log('Each exception has a specific HttpStatus');
111console.log('Hierarchy: base -> specific exceptions');
112Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. Which class should a base Custom Exception class extend?
2. Which Custom Exception field indicates whether the error can be automatically recovered?
These are 2 of 3 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Code editor
Complete the abstract class LegionaryException extends Error with fields: code (string), severity ('LOW'|'MEDIUM'|'HIGH'|'CRITICAL'), recoverable (boolean), context (Record), and a toJSON() method
- Vertical ordering
Order the exception severity levels from least to most critical
- Click in order
Arrange the elements of a concrete Custom Exception definition in the correct order
- Code editor
Complete the TributeNotFoundException class that extends LegionaryException and in the constructor(tributeId) calls super with message, code: 'TRIBUTE_NOT_FOUND' and severity: 'MEDIUM'