DEV Community

Todsapon Boontap
Todsapon Boontap

Posted on

Enhancing Error Handling in TypeScript: Leveraging Functional Concepts for Better Practices

Introduction

As someone who tends to prioritize addressing errors upfront, encountering an error that can arise without our code being ready to catch it makes me feel uneasy.
I believe error handling is a crucial topic that doesn't receive as much attention as it deserves, especially when working with JavaScript and TypeScript. There are gaps that need to be filled in these languages, and I personally believe we should put in more effort to handle errors effectively, rather than solely relying on native features.

OK, today I'll begin with:

  • The native approach in TypeScript/JavaScript.
  • A common approach that is widely used and can be found in many projects.
  • Then, a more preferable and better way.

Starting with what TypeScript/JavaScript natively provides for error handling: try and catch.

try {
    op1()
    op2()
} catch(e) { handle(e) }
Enter fullscreen mode Exit fullscreen mode

Here, all the operations within the try block are executed, and if an error occurs, the catch block is triggered.


A common approach

Let's assume we're working on the backend side. We've structured our operations into different layers such as Validation and Database layers.

For the purpose of this example, let's create two types of errors and simulate fake operations. One operation will result in a promise rejection or error, while the other will simulate success. This might look something like this:

type ErrorFromDBLayer = { msg: string };
type ErrorFromValidationLayer = { errors: { field: string; reason: string }[] };

let validationOp = Promise.reject({
  errors: [{ field: "x", reason: "number only" }],
} as ErrorFromValidationLayer);
let dbOp = Promise.resolve({} as ErrorFromDBLayer);
Enter fullscreen mode Exit fullscreen mode

Now, let's imagine that we're working on a route handling function. This function will execute the previously created fake operations.

async function routeHandler() {
  try {
    await validationOp;
    await dbOp;
  } catch (e: unknown) { // handle errors }
}
Enter fullscreen mode Exit fullscreen mode

To me, this approach seems prevalent, present in about 3/4 of projects. It appears quite normal and understandable, right?

However, let's take a step back, calm down, and analyze this pattern.

One concern that comes to mind is within the try block: how can we distinguish which operation fails and what type of error it encounters? TypeScript lacks a native way to specify Promise reject function return types, leading all errors to fall into the catch block. In such cases, what is typically done is creating a function to handle all operation errors. Let's start by implementing the handleErrors function.

Firstly, I'll create a type guard utility function for error types.

// define type gard util funcs
function isErrorFromDBLayer(t: object): t is ErrorFromDBLayer {
  return "msg" in t;
}
function isErrorFromValidationLayer(
  t: object,
): t is ErrorFromValidationLayer {
  return "errors" in t;
}
Enter fullscreen mode Exit fullscreen mode

Then the handle errors function and update our catch block. Like this

try { ... } catch(e: unknown) { handleErrorsByTypeNarrowing(e) }

function handlerErrorByTypeNarrowing(e: unknown) {
  if (typeof e === "object") {
    let errObj = e as object;
    switch (true) {
      case isErrorFromDBLayer(errObj):
        console.log("error msg from db layer", errObj.msg);
        break;
      case isErrorFromValidationLayer(errObj):
        console.log("error msg from validation layer", errObj.errors);
        break;
      default:
        console.log("unknown error");
    }
  } else {
    console.log("unknown error");
  }
}
Enter fullscreen mode Exit fullscreen mode

As observed, this function effectively manages errors through type narrowing with a type guard function.

So what's wrong with this approach?

Here are a few concerns that come to mind:

  • When dealing with more than just two operations, or when working with code we haven't authored ourselves, we're forced to delve into all interacting functions to identify error types. This can become quite cumbersome and time-consuming for developers, as it requires loading a lot of context beforehand to proceed.
  • As developers, we aim to avoid writing code that requires readers or our future selves to invest a significant amount of time understanding context just to continue working. This can slow down development and productivity.
  • Due to the lack of error typing support in Promises, manual detection of error types and handling becomes necessary. Consequently, when introducing more operations, the handle function needs frequent updates. This lack of scalability may lead to error-prone code.

A Better approach

In this approach, we'll explore a type commonly used in functional programming known as the Result type. This generic type can represent either an Ok or Error state. Let's gain a better understanding by examining its type definition.

type Err<F> = { identifier: "fails"; reason: F };
type Ok<T> = { identifier: "success"; value: T };
type Result<T, F> = Ok<T> | Err<F>;
Enter fullscreen mode Exit fullscreen mode

We can examine languages like Rust, where the type definition appears quite similar. Since TypeScript enums don't allow embedding associated values, we can implement a similar concept using discriminated unions.

Let's proceed by updating our operation functions.

const validationOp: Promise<Result<{}, ErrorFromValidationLayer>> =
  Promise.reject({
    identifier: "fails",
    reason: { errors: [{ field: "x", reason: "number only" }] },
  });

let dbOp: Promise<Result<{}, ErrorFromDBLayer>> = Promise.resolve({
  identifier: "success",
  value: {},
});
Enter fullscreen mode Exit fullscreen mode

As observed, our Promise now has a specified result type that supports Ok and Err types, effectively enhancing the Promise's capabilities.

Now, let's examine our updated implementation of the routeHandler function.

async function routeHandler() {
  const validationOp_result = await validationOp;
  if (validationOp_result.identifier === "fails") {
    // log error, write an error response

    // also get benefit of auto intellisence, by discriminated union!
    // processValidation_result.reason.errors
    return;
  }

  const dbOp_result = await dbOp;
  if (dbOp_result.identifier === "fails") {
    // log error, write an error response
    return;
  }

  // return success response, etc..
}
Enter fullscreen mode Exit fullscreen mode

After this lengthy journey, let's reflect on what improvements we've achieved.

The primary enhancement is the complete removal of the handleError function, rendering it unnecessary.

Why?

As evident in our updated routeHandler function:

  • We now possess the capability to catch errors early and exit the operation flow, granting more control.
  • The power of discriminated types becomes apparent within the if true block, where our editor or IDE offers auto-completion for error types.
  • Due to our knowledge of the error type, we can handle it with type safety, eliminating the need for implementing type narrowing functions to detect errors.
  • With the Result type in place, we can seamlessly add numerous operations without the necessity to navigate to the function source for its definition. This scalability is achieved without requiring modifications.

Conclusion

Although TypeScript lacks direct support for error typing, we can draw upon concepts from functional programming to enhance our approach. The Result type, found in various functional languages, provides a foundation for further exploration. For instance, implementing traditional functional-style mapping functions to map Ok or Err values and enable type chaining is a potential avenue to explore, albeit beyond the scope of this discussion.

I hope this idea proves helpful for better error handling!

Enjoy the enhancements!

Top comments (0)