Friday, January 05, 2024 · 4 min read
typescript

TypeScript Error Handling: A Closer Look into Custom Error Objects

Custom Error Handling with Examples

Substack Banner TS101.png

In my previous post TypeScript Pattern: Asynchronous Operations, a reader asked a thoughtful question, which I believe is worth further discussion. The reader was curious about a specific aspect of error handling: Why extract an error message from an existing Error object and then use it to create a new Error object? This question refers to the section titled “Structured Error Handling in Asynchronous Operations” in my previous article.

For those who haven’t read it or need a quick refresher, here’s a link to the post. Understanding this context will enrich your grasp of the detailed explanation I’m about to delve into.

So, in this extended post, I would like to shed more light on the topic, especially for those who might have similar doubts about such a pattern.

The original snippets from the previous post:

type ErrorBoundary<T, E extends Error> = {
  status: 'success';
  data: T;
} | {
  status: 'error';
  error: E;
};

async function asyncHandleError<T>(
  fn: () => Promise<T>,
  createError: (message?: string) => Error
): Promise<ErrorBoundary<T, Error>> {
  try {
    const data = await fn();
    return { status: 'success', data };
  } catch (error) {
    const errorMessage = error instanceof Error ? error.message : 'Unknown error';
    return {
      status: 'error',
      error: createError(errorMessage)
    };
  }
}

async function riskyAsyncOperation(): Promise<string> {
  const someCondition = false;
  if (someCondition) {
      throw new Error('Failure');
  }
  return 'Success';
}

async function handleOperation() {
  const result = await asyncHandleError(riskyAsyncOperation, (message) => new Error(message));
  
  if (result.status === 'success') {
      console.log(result.data); // Outputs 'Success'
  } else {
      console.error(result.error.message); // Outputs the error message, if any
  }
}

// Execute the operation
handleOperation();

The primary reason for this approach is to establish a consistent error handling structure across different parts of the application. Below are the 3 key reasons why this design can be beneficial:

1. Customized error transformation

In applications interacting with various external services, each service might throw errors in different formats. By extracting the message and creating a new Error, we can transform and standardize these errors. This process allows us to tailor the error information to our specific needs, ensuring clarity and maintainability.

2. Consistent error handling

Using the createError function to generate errors ensures that all errors, regardless of their source, are handled predictably and consistently. This consistency is crucial in complex applications, for error logging and to keep error messages displayed consistently to maintain user experience.

3. Abstraction and Flexibility

The createError function serves as a single point of control for error creation logic. This approach provides the flexibility to customize error handling and easily adapt to changes without major refactoring in the future.

Example

To illustrate this approach, let’s consider an example involving two different services:

// errorHandlers.ts
function handleServiceAError(errorMsg?: string): ServiceAError {
  // Logic specific to handling Service A errors

  const message = errorMsg ?? 'Default error message for Service A';

  return new ServiceAError(message, 'SPECIFIC_ERROR_CODE_A');
}

function handleServiceBError(errorMsg?: string): ServiceBError {
  // Logic specific to handling Service B errors

  const message = errorMsg ?? 'Default error message for Service B';

  return new ServiceBError(message, 'SPECIFIC_ERROR_CODE_B');
}

// services.ts
// Service A
async function serviceAOperation(): Promise<string> {
  const someCondition = false;
  if (someCondition) {
    throw new ServiceAError('Error in Service A', 'SERVICE_A_ERROR');
  }

  return 'Result from Service A';
}

async function requestA() {
  const result = await asyncHandleError(serviceAOperation, handleServiceAError);
  // Handle the result from Service A...
}

// Service B
async function serviceBOperation(): Promise<string> {
  const anotherCondition = false;
  if (anotherCondition) {
    throw new ServiceBError('Error in Service B', 'SERVICE_B_ERROR');
  }

  return 'Result from Service B';
}

async function requestB() {
  const result = await asyncHandleError(serviceBOperation, handleServiceBError);
  // Handle the result from Service B...
}


// Errors.ts
class ServiceAError extends Error {
  constructor(public message: string, public code: string) {
    super(message);
    this.name = "ServiceAError";
  }
}

class ServiceBError extends Error {
  constructor(public message: string, public code: string) {
    super(message);
    this.name = "ServiceBError";
  }
}

In this example, ServiceAError and ServiceBError are custom error types tailored to each service’s specific needs. They could include additional information like specific error codes or context data related to the service. This approach demonstrates how we can manage errors from different sources in a consistent, controlled manner, enhancing the error handling of the application.

Connecting the Dots

So, why is this extended explanation necessary? Through the reader’s question, I realized the need to clarify the nuances of this pattern. Not only does it underline the importance of structured error handling in complex TypeScript applications, but it also highlights how customized approaches can significantly improve the maintainability and clarity of your codebase.

Closing Thoughts

I hope this post, inspired by the reader’s question, provides clearer insights into our error handling strategy in TypeScript. As always, I encourage you to take these concepts, experiment with them, and see how they can improve the error handling mechanisms in your TypeScript projects.