Enhancing Error Handling in TypeScript: Leveraging Functional Concepts for Better Practices
Published: 2023-12-08 22:49:26

## 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`.

```typescript
try {
	op1()
	op2()
} catch(e) { handle(e) }
```

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:


```typescript
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);
```


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


```typescript
async function routeHandler() {
  try {
    await validationOp;
    await dbOp;
  } catch (e: unknown) { // handle errors }
}
```

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.

```typescript
// 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;
}
```


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

```typescript
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");
  }
}
```

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.

```typescript
type Err<F> = { identifier: "fails"; reason: F };
type Ok<T> = { identifier: "success"; value: T };
type Result<T, F> = Ok<T> | Err<F>;
```

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.

```typescript
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: {},
});
```

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.

```typescript
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..
}
```

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!