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.
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
- JavaScript's built-in class. Beware of two plausible-sounding names that do not exist: Error
and NestException
. The latter comes from Java and C#; JavaScript has no such thing.RuntimeException
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:
is a fixed case identifier, for instance code
'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.
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.severity
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.recoverable
carries accompanying data - a tribute id, a legion name, whatever helps make sense of the situation when reading a log. The context
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.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.
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, @name: 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.
Crises carry case codes, not sentences:
Error - JavaScript's built-in class,HttpException, which belongs to the transport layer and would make the service know about HTTP; NestException and RuntimeException do not exist at all,@Catch(LegionaryException) catches the whole family,code is a fixed case identifier - recognise errors by it, never by the message text,severity from least to most critical: LOW → MEDIUM → HIGH → CRITICAL,recoverable says whether the error can be fixed automatically - it steers retries,context carries 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,extends the 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.