Skip to main content

NestJS

Map @gentleduck/error instances to NestJS HTTP responses with an ExceptionFilter, using statusCode for compatibility with Nest's base filter.

Overview

KitError exposes statusCode alongside status specifically so it lines up with what NestJS's base HttpException/exception-filter machinery reads. Writing a filter is the idiomatic way to turn every thrown kit error into a consistent JSON body without touching each controller.

Full runnable example: examples/nestjs.

Exception filter

import { type ArgumentsHost, Catch, type ExceptionFilter } from '@nestjs/common'
import type { Response } from 'express'
import { AppError } from './errors'

@Catch(AppError)
export class AppErrorFilter implements ExceptionFilter {
  catch(err: AppError, host: ArgumentsHost) {
    const res = host.switchToHttp().getResponse<Response>()
    res.status(err.statusCode).json(err.toJSON())
  }
}
import 'reflect-metadata'
import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
import { AppErrorFilter } from './app-error.filter'

async function bootstrap() {
  const app = await NestFactory.create(AppModule)
  app.useGlobalFilters(new AppErrorFilter())
  await app.listen(3000)
}
bootstrap()

Because @Catch(AppError) names the class createErrorKit returned, only instances of this kit are routed here. An unrelated thrown value falls through to Nest's default filter, which is the correct behavior: a kit's errors are the ones this filter knows how to shape, nothing else.

Throwing from a route

@Get()/@Post()/etc. register the route; nothing about throwing a kit error is NestJS-specific once inside the handler. The filter above catches it regardless of which decorator dispatched the request.

import { Controller, Get, Param } from '@nestjs/common'
import { asAppError, throwAppError } from './errors'

@Controller()
export class WidgetsController {
  @Get('widgets/:id')
  findOne(@Param('id') id: string) {
    const widget = WIDGETS[id]
    if (!widget) throwAppError('NOT_FOUND', { resource: 'widget', id })
    return widget
  }

  @Get('boom')
  boom() {
    try {
      throw new Error('connection refused')
    } catch (err) {
      throw asAppError(err, 'STORAGE_FAILED', { cause: String(err) })
    }
  }
}

findOne throws directly; boom shows the other shape: catching a plain Error from somewhere else and converting it with asAppError before it leaves the handler. Both reach the same AppErrorFilter, because @Catch(AppError) matches on the thrown value's class, not on which method or decorator produced it.

Throwing from a service

import { Injectable } from '@nestjs/common'
import { throwAppError } from './errors'

@Injectable()
export class WidgetsService {
  async findOne(id: string) {
    const widget = await db.widgets.findById(id)
    if (!widget) throwAppError('NOT_FOUND', { resource: 'widget', id })
    return widget
  }
}