Error handling with Either<Type>

We have started a new small internal project for automating a few workflows around counting worked hours and time offs.

Application architecture

The application is a slack bot on top of node js, TypeScript and PostgreSQL database. We use 3rd party APIs to fetch data about the times which we need to accumulate and process to calculate valuable information to our users.

It will run on a server with a possibility of migrating into serverless when we decide if it's a good use case for it. We've decided that we won't experiment too much for development as we want to make it useful first.

API design

As it is not a classic web server application I had to come up with slightly different error handling as we are used to. I've been trying to find a semi-functional API with all the good practices described in my guide on error handling. The main goal is to not let users be presented with internal information about errors. We want to show user-friendly messages instead. I call this API semi-functional as I didn't want to use monads and go 100% functional. We use simple asynchronous functions to handle interactions. The goal is to handle errors that are expected. Unexpected errors should still be thrown and caught by an "Error boundary" around the whole app that will handle and log the error.

Error types

Let's create 2 types of Errors that all the other Errors can be extended from. These will allow us to distinguish if the thrown Error should be presented to the user. We want to handle InternalErrors differently. We might log them to different logs or trigger alarms before we convert them to a different PublishableError.

export class PublishableError extends Error {
  publishable = true
  constructor(message: string) {
    super(message)
    Object.setPrototypeOf(this, PublishableError.prototype)
  }
}

export class InternalError extends Error {
  publishable = false
  constructor(message: string) {
    super(message)
    Object.setPrototypeOf(this, InternalError.prototype)
  }
}

Then, we can create multiple app-specific errors by extending from these two.

We need to set Object.setPrototypeOf(...) as TypeScript introduced a breaking change that may cause the inheritance to now work properly.

Handling errors

I wanted to have a similar style of handling the error as the Ramda's tryCatch function. I couldn't just use Ramda's tryCatch as it doesn't support asynchronous functions. I've found inspiration in the fp-ts TaskEither type.

I've come up with the following solution:

/**
 * Tuple of error and a result of an asynchronous task that might throw an error
 */
export type Either<ResultType, ErrorType extends Error> = Promise<
  [error: null, result: ResultType] | [error: ErrorType, result: undefined]
>

/**
 * Try to execute an async function and return a tuple of `[Error, ResultType]`
 * If the operation is successful error will be `null`
 * If the operation fails, the `errorHandler` runs with the exception that was thrown, Result is `undefined`
 * It catches all errors that might happen in the passed async task
 * It will not catch any errors that are thrown from the `errorHander`
 */
export function wrapWithErrorHandler<
  ErrorType extends Error,
  // TS is still able to inherit function parameters
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  FunctionArgs extends Array<any>,
  ResultType
>(
  errorHandler: (error: ErrorType | unknown) => ErrorType,
  fn: (...args: FunctionArgs) => Promise<ResultType>
): (...args: FunctionArgs) => Either<ResultType, ErrorType> {
  return async (...args: FunctionArgs) => {
    try {
      const result = await fn(...args)
      return [null, result]
    } catch (exception) {
      const errorHandlerResult = errorHandler(exception)
      return [errorHandlerResult, undefined]
    }
  }
}

I struggled with the typings for a few minutes but I was impressed with the result. The goal of this function is to wrap an asynchronous function that might throw errors and let the errorHandler process the error and return an alternative result.

As most of the errors will be possibly handled by the same errorHandle we can create a genericErrorHandler that might be used in most situations.

/**
 * Generic Error handler that should be used in most common use cases
 * It will throw an error if any unexpected error is thrown
 * When used with `wrapWithErrorHandler` it is able to catch most of the expected errors
 */
export function genericErrorHandler<ErrorType extends Error>(
  exception: ErrorType | unknown
) {
  if (exception instanceof PublishableError) {
    // These errors are usually handled no need to log them
    return exception
  }
  if (exception instanceof InternalError) {
    // Log error and return publishable generic error
    return new PublishableError(
      "I'm sorry for the inconvenience, but my circuits have been overloaded :zap:"
    )
  }

  if (exception instanceof Error) {
    // This is unexpected error, better log it and let rethrow it further away
    throw exception
  }

  return exception as ErrorType
}

To spare some characters of redundant code I've applied a partial application principle to use these functions together. At first, I've tried to use Ramda's partial, but it was not able to inherit correct types, so I've just written the partially applied function myself:

/**
 * Helper function with applied `genericErrorHandler` to `wrapWithErrorHandler`
 */
export function wrapWithGenericErrorHandlerFunction<
  FunctionArgs extends Array<any>,
  ResultType
>(fn: (...args: FunctionArgs) => Promise<ResultType>) {
  return wrapWithErrorHandler(genericErrorHandler, fn)
}

Usage

To sum it up, I'd like to present you the way how this API is used:

const [error, memberId] = await wrapWithGenericErrorHandlerFunction(getUserId)(
  user.token
)
if (error) {
  await respond(error.message)
  return
}

This API allows me to keep the separation of concerns between the tasks and error handling into two separate functions. It also allows us to use the wrapWithErrorHandler with different errorHandler when we need to take special care. Also, I am still able to throw errors from the errorHandler that might be caught by a different errorHandler from an upper scope.

Thank you for reading!