Node.js and RFC compliant error message

When developing interfaces, the question arises: “How do I handle errors?” My answer is: “As centrally as possible and in accordance with standards.” I'll show you which approach has proven successful for me in the Node.js environment. Let's get started!

5 minutes

In this article, I demonstrate my approach to outputting interface errors of the “Invalid Parameter” category in a standards-compliant manner. For this, I adhere to RFC 7807, which specifies that problem details are transported in a machine-readable HTTP response. The prerequisites for this are the setup of Node.js with TSOA, the type definition in Node.js with TSOA including request body validation, and the implementation of my base error class.

The following steps are necessary for implementation:

  1. Defining the appropriate class for the “InvalidParams” error.
  2. Mapping the error classes into an RFC-compliant problem document.
  3. Creating error middleware for Express.
  4. Integrating it into the Express application.

1. The InvalidParams Error

We provide detailed information about the invalid parameters so that we can later display in the UI which input caused a problem (e.g., in the form of an error message and a red border around the input field).

// Defining the structure of validation errors in analogy to TSOA
export interface ValidationErrors {  
  [name: string]: {  
    message: string  // The message shown in the frontend
    received?: unknown  // The value we received in the API, if available
    expected?: unknown  // The value we expected, if given
    code?: string  // To give a hint about what we expect as a machine-readable value
  }  
}  

// Extending BaseError with the newly defined structure
interface InvalidParamsErrorOptions extends BaseErrorOptions {  
  validationResult: ValidationErrors  
}

The new class extends the BaseError according to the type definition mentioned above.

class InvalidParamsError extends BaseError {  
  public validationResult: ValidationErrors  

  constructor(options: InvalidParamsErrorOptions) {  
    const { detail = 'Invalid parameters', cause, validationResult } = options  
    super({ detail, cause, statusCode: 422 })  

    this.validationResult = validationResult  
  }  

  public toJSON() {  
    return {  
      ...super.toJSON(),  
      validationResult: this.validationResult,  
    }  
  }  

  public toString() {  
    return JSON.stringify(this.toJSON())  
  }
}

2. Mapping to an RFC-Compliant Problem Document

Two suitable NPM packages are available for basic functionality:

npm install http-problem-details http-problem-details-mapper

Using these two NPM packages, we define the mapping to the problem document. We ensure that the status code and error message are written in the status and detail fields, respectively.

import { ProblemDocument } from 'http-problem-details'  
import { ErrorMapper } from 'http-problem-details-mapper'

export class InvalidParamsMapper extends ErrorMapper {   
  mapError(error: InvalidParamsError) {  
    return new ProblemDocument(  
      {  
        status: error.statusCode,  
        title: 'Invalid parameter',  
        type: 'validation-error',  
        detail: error.detail,  
      },  
      {  
        invalidParameter: error.validationResult,  
      },  
    )  
  }  
}

3. The necessary error middleware implementations

We still need two components to integrate this into the Express application. Firstly, the component that converts the TSOA error into our InvalidParams error class.

import { ErrorRequestHandler, NextFunction, Request, Response } from 'express'  
import { ValidateError } from 'tsoa'  

import InvalidParamsError from '@/error/InvalidParamsError'  

const validationErrorMiddleware: ErrorRequestHandler = (  
  err: unknown,  
  _req: Request,  
  _res: Response,  
  next: NextFunction,  
) => {  
  if (err instanceof ValidateError) {  
    next(new InvalidParamsError({ detail: 'Validation failed', validationResult: err.fields }))  
  }  

  next(err)  
}  

export default validationErrorMiddleware

Secondly, the component that converts the error class into the desired HTTP response.

import { ErrorRequestHandler, NextFunction, Request, Response } from 'express'  
import { IMappingStrategy } from 'http-problem-details-mapper'  

interface HttpProblemResponseOptions {  
  strategy: IMappingStrategy  
}  

function HttpProblemResponse(options: HttpProblemResponseOptions): ErrorRequestHandler {  
  const { strategy } = options  

  return function HttpProblemDetailsMiddleware(  
    error: Error,  
    _request: Request,  
    response: Response,  
    next: NextFunction,  
  ): void {  
    const problem = strategy.map(error)  

    // in case we cannot map the error, we will not handle it  
    if (!problem) {  
      next(error)  
      return  
    }  

    response.statusCode = problem.status  
    response.setHeader('Content-Type', 'application/problem+json')  
    response.json(problem)  
  }  
}  

export default HttpProblemResponse

4. Integration into the Express Application

As a final step, we integrate all components into the Express application:

  • We create the mapping for the InvalidParams error mapper so that it translates the Error class to ProblemDocument.
  • We include the validationErrorMiddleware, which converts the TSOA error into an InvalidParams error, enabling the mapping to take effect.
  • We activate the mapper's translation.
// Set up mapping to ensure translation to ProblemDocument
const mapperRegistry = new MapperRegistry()
    .registerMapper(new InvalidParamsMapper())
    // here you register other errors you want to map

const ErrorMappingStrategy = new DefaultMappingStrategy(mapperRegistry)

// This translates the tsoa validation error into an InvalidParamsError,  
// which gets resolved into an RFC error response in the last handler  
app.use(validationErrorMiddleware)

// The RFC 7807 error middleware has to be the last app.use() to work as intended  
// It will map the thrown errors to the RFC applying responses  
app.use(HttpProblemResponse({ strategy: ErrorMappingStrategy }))

Conclusion

This article demonstrates how several components are combined to achieve the goal. With this structure, we built the platform to define further errors and centrally translate them into an RFC-compliant HTTP response. Examples include UnauthenticatedError and UnauthorizedError, which I will discuss in subsequent articles on Node.js + TSOA + authentication. Similarly, there's the NotFoundError, which is important with Prisma.

If you want to know how I apply all of this in a project and how I organize my code, then

Let's look at the code together
call to action background image

Subscribe to my newsletter

Receive once a month news from the areas of software development and communication peppered with book and link recommendations.