We use cookies to enhance your experience on the site
CodeWorlds

Custom Exceptions - special situations

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, @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.

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;
    NestException
    and
    RuntimeException
    do 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,
  • 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,
  • a concrete exception: the class,
    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.

Go to CodeWorlds