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
}
}
throwAppError's return type is never, so a controller method that calls it in every branch
still type-checks against a non-void return type. TypeScript sees the call as unreachable
past that point, the same as a native throw.