How to handle Errors in AWS Lambda using Typescript

Borislav Hadzhiev

Last updated: Feb 26, 2024
5 min


# Best Practices for Error Handling in AWS Lambda using TypeScript

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:

  • Request validation error - the information the user sent doesn't satisfy the API endpoint signature. I.e. the API is expecting a field named age that is an integer and receives a field named address which is a string - status code 400.
  • Database error - connection problems with the database. For example, rejecting connections because of throttling - status code 500.
  • Bad request error - the user-provided information could not pass the necessary steps to complete the request. For example, a user is signing up and enters their username but the username is already taken - status code 400.
  • Not found error - the requested resource does not exist - status code 404.

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 => ({ 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:

  • Cannot be directly instantiated.
  • Are used to set up requirements via abstract properties/methods to be implemented on the child class that extends the abstract class.
  • Create a 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.

# Additional Resources

You can learn more about the related topics by checking out the following tutorials:

I wrote a book in which I share everything I know about how to become a better, more efficient programmer.
book cover
You can use the search field on my Home Page to filter through all of my articles.