Last updated: Feb 26, 2024
Reading time·5 min
The practices for handling errors on the backend are similar regardless if you use Express.js, AWS Lambda or a framework.
Regardless if you get a request validation error
, database error
,
bad request error
or not found error
, you should return the same error
signature so any frontend that is calling your APIs doesn't have to be concerned
about the type of error they received.
Some common error types are:
age
that
is an integer and receives a field named address
which is a string -
status code 400.We often want to include information like the status code with the error types, as well as the input field name that failed the validation check, etc.
You can use a try/catch
block to customize the error that is returned to the
client.
In a catch
clause you normalize the error. If errors happen anywhere else in a
nested try/catch
, you massage them if you have to and rethrow the specific
kind of error for that situation.
When using the default throw new Error()
function in JavaScript, we are
limited to a couple of properties like message, stack, name.
However, we often want to provide more information. In JavaScript, you could attach some properties to the Error.
const error = new Error('Invalid email or password'); error.reasons = [ {field: 'email', message: 'Email is invalid'}, {field: 'password', message: 'Password is required'}, ];
If we now throw the error we can access the reasons property inside of the
catch
block.
However, this wouldn't work in TypeScript because the Error
object doesn't
have a reasons
property.
In TypeScript, we can't just attach some random property on the Error object.
So we want an object like Error
, but one that we can extend and add some
more custom properties to.
This is usually a sign that we want to subclass something and add the custom properties to the subclass.
The extra properties allow us to communicate more information from the request handler to the catch block.
We can subclass the Error
object as follows.
export abstract class CustomError extends Error { abstract statusCode: number; // taking message just to pass it to the Error constructor // because these messages would still be printed inside our error Logs constructor(message: string) { super(message); // because we are extending a built in class Object.setPrototypeOf(this, CustomError.prototype); } abstract serializeErrors(): {message: string; field?: string}[]; }
And the CustomError
can then be extended as follows.
import {CustomError} from './custom-error'; export class RequestValidationError extends CustomError { statusCode = 400; // example using the `yup` validation library constructor(private errors: {inner: {path: string; message: string}[]}) { super('Invalid request parameters'); Object.setPrototypeOf(this, RequestValidationError.prototype); } serializeErrors(): {field?: string; message: string}[] { return this.errors.inner.map(e => ({ field: e.path, message: e.message, })); } }
Or as follows.
import {CustomError} from './custom-error'; export class NotFoundError extends CustomError { statusCode = 404; constructor(public message: string) { super('Item not found'); Object.setPrototypeOf(this, NotFoundError.prototype); } serializeErrors(): {message: string}[] { return [{message: this.message}]; } }
CustomError
is an abstract
class because it allows us to enforce methods and
properties to be implemented by the classes that extend it.
Note that Abstract classes:
Class
when translated to JavaScript, so we can use instanceof
checks, as opposed to interfaces which get removed. When we translate
interfaces to JavaScript all interfaces get removed, but abstract classes do
NOT, so you can use instanceof
checks with Abstract classes but NOT with
interfaces.In short, if a class A is an abstract class and a class B extends class A, then class B has to implement all the abstract properties and methods defined on class A.
In our case, all of our Error classes extend CustomError
which enforces the
signature of methods and properties that have to be implemented on the child
classes.
Where this helps us is in the catch
block. Since all of the Error
classes
implement the same signature and are instances of CustomError
, we can do
things similar to the following.
async function handler() { try { await somethingThatCouldError(); } catch (error) { if (error instanceof CustomError) { return response({ statusCode: error.statusCode, body: {errors: error.serializeErrors()}, }); } return response({ statusCode: 400, body: { errors: [ { message: "We don't know what happened, return a generic message to avoid leaking secret information, and log the error to debug later", }, ], }, }); } }
Any errors that are instanceof
CustomError are safe for user consumption
because we have thrown them ourselves.
Therefore we just return them to the user in the form of an object of type
type errors = {message: string; field?: string}[]
, where the field property is
used for request validation, i.e. form fields.
In addition, we now have the statusCode
included in the Error
class.
You could wrap the code from the catch block above into a function and just pass in the error to the error handler in the catch block.
catch(error) { errorResponse(error) }
I use a package called yup
for validation. However, it doesn't return errors
and throws them instead.
So I have to wrap the yup
validation call as follows.
export async function validateSchema<T>(validation: Promise<T>): Promise<T> { try { return await validation; } catch (error) { console.log('YUP Error: ', error); throw new RequestValidationError(error); } } const {text} = await validateSchema( ValidationSchema.validate(body, {abortEarly: false}), );
This allows me to throw my own errors.
Otherwise, in the catch block where I check for the type of error, I would have
to include the ValidationError
for yup
, and I could not extract the catch
block logic into a generic respondError
function because any of my Lambda
functions could throw an error but not every function needs to bundle yup
and
validate.
Having a consistent signature for error responses allows client apps that consume the API to not be concerned about handling different error responses.
On the backend, I can extend my CustomError
class with any type of error that
makes sense for my application and implements the statusCode
property and the
serializeErrors
method.
If the error that I get in my catch block is instanceof
CustomError all I
have to do is return the statusCode
along with a call to serializeErrors
.
You can learn more about the related topics by checking out the following tutorials: