Approach to handle errors passed up the call chain using Eithers

183 views Asked by At

I'm currently learning more about functional programming and alternative approaches to error handling than what I'm used to (try/catch mainly). And have been looking at the Either monad in various programming languages. I've been attempting to apply this concept to a small, simple app which uses Express and fp-ts.

Consider I have the following architecture for handling a request (lets assume it's to retrieve an entity from the database):

Express -> route handler -> controller -> repository -> data source -> database

That gives multiple places in which errors have the opportunity to occur. Firstly let's look at the data source. I am attempting to restrict this to an interface allowing me to swap data sources in the future if needed. So my data source interface initially looked like:

export type TodoDataSource = {
  findAll(): Promise<ReadonlyArray<TodoDataSourceDTO>>;
  findOne(id: string): Promise<TodoDataSourceDTO | null>;
  create(data: CreateTodoDTO): Promise<TodoDataSourceDTO>;
  update(id: string, data: UpdateTodoDTO): Promise<TodoDataSourceDTO>;
  remove(id: string): Promise<TodoDataSourceDTO>;
};

This data source is then passed to the repository factory function when created:

export function createTodoRepository(
  dataSource: TodoDataSource,
): TodoRepository {
  return {
    findAll: async () => await findAll(dataSource),
    findOne: async (id: string) => await findOne(dataSource, id),
    create: async (data: CreateTodoDTO) => await create(dataSource, data),
    update: async (id: string, data: UpdateTodoDTO) =>
      await update(dataSource, id, data),
    remove: async (id: string) => await remove(dataSource, id),
  };
}

In a similar way, my repository implementation is designed to meet the contract of the TodoRepository interface:

export type TodoRepository = {
  findAll(): Promise<ReadonlyArray<Todo>>;
  findOne(id: string): Promise<Todo | null>;
  create(data: CreateTodoDTO): Promise<Todo>;
  update(id: string, data: UpdateTodoDTO): Promise<Todo>;
  remove(id: string): Promise<Todo>;
};

The problem I'm having, is once I attempt to apply the approach of using Eithers my interfaces start to get quite closely coupled and quite verbose.

Firstly, let me update the data source to using Either as it's return type:

import * as TaskEither from 'fp-ts/TaskEither';

export type DataSourceError = { type: "DATA_SOURCE_ERROR"; error?: unknown };

export type TodoDataSource = {
  findAll(): Promise<
    TaskEither.TaskEither<DataSourceError, ReadonlyArray<TodoDataSourceDTO>>
  >;
  findOne(
    id: string,
  ): Promise<TaskEither.TaskEither<DataSourceError, TodoDataSourceDTO | null>>;
  create(
    data: CreateTodoDTO,
  ): Promise<TaskEither.TaskEither<DataSourceError, TodoDataSourceDTO>>;
  update(
    id: string,
    data: UpdateTodoDTO,
  ): Promise<TaskEither.TaskEither<DataSourceError, TodoDataSourceDTO>>;
  remove(
    id: string,
  ): Promise<TaskEither.TaskEither<DataSourceError, TodoDataSourceDTO>>;
};

Not so bad. This will handle anything unexpected being thrown by the underlying data source - and we can catch and convert to an Either. Great.

Now to my repository interface:

import type * as TaskEither from "fp-ts/TaskEither";
import { type DataSourceError } from "../../infra/data-sources/todo.data-source";
import { type ParseError } from "../../shared/parsers";

export type TodoNotFoundError = { type: "TODO_NOT_FOUND"; id: string };

export type TodoRepository = {
  findAll(): Promise<
    TaskEither.TaskEither<DataSourceError | ParseError, ReadonlyArray<Todo>>
  >;
  findOne(
    id: string,
  ): Promise<
    TaskEither.TaskEither<
      DataSourceError | ParseError | TodoNotFoundError,
      Todo
    >
  >;
  create(
    data: CreateTodoDTO,
  ): Promise<TaskEither.TaskEither<DataSourceError | ParseError, Todo>>;
  update(
    id: string,
    data: UpdateTodoDTO,
  ): Promise<TaskEither.TaskEither<DataSourceError | ParseError, Todo>>;
  remove(
    id: string,
  ): Promise<TaskEither.TaskEither<DataSourceError | ParseError, Todo>>;
};

Now my repository and data source are starting to become coupled with their error types. However the repository is now starting add some of it's own error types in here such as PARSE_ERROR (when parsing the DTO to an domain entity fails) and TODO_NOT_FOUND (when the findOne function returns null - although probably better to use an Option).

I guess it's not too bad, but we're only two calls deep, and my repository shouldn't handle the response to the user that something has gone wrong, and I should let those errors pass up the call chain back to the controller so the controller can respond with the appropriate response code/text.

In this scenario I suppose it easy enough to handle as the call stack is simple, but what if the controller ends up calling multiple services and/or repositories and I end up having to type all the interfaces with their possible left error types.

Am I approaching this incorrectly? Or is this expected?

Another approach would be to mix both approaches using try/catch for exceptions, and catch them at the controller. And using Either for errors I can handle earlier in the call stack.

1

There are 1 answers

0
Awais khan On

You've delved into an essential aspect of error handling in functional programming, especially with the Either monad. While the verbosity and increased coupling in types might seem overwhelming initially, it's quite common in FP to handle error types explicitly at each layer.

Your concern about propagating errors up the call stack is valid. As you rightly mentioned, the controller shouldn't necessarily handle the low-level error details; it's better suited to interpret errors in the context of the request and formulate the appropriate response.

One potential refinement could be introducing a common error union type or a more granular error-handling strategy. For instance:

type AppError =
  | DataSourceError
  | ParseError
  | TodoNotFoundError
  // Add more specific errors as necessary

// In the repository interface
export type TodoRepository = {
  findAll(): Promise<TaskEither.TaskEither<AppError, ReadonlyArray<Todo>>>;
  findOne(id: string): Promise<TaskEither.TaskEither<AppError, Todo>>;
  // ...
}

This consolidation might reduce some of the verbosity in the types and provide a more centralized way to handle errors. Additionally, as your application grows, it's crucial to consider the trade-offs between explicit error handling and the complexity it introduces.

Regarding mixing try/catch with Either, it's a valid approach. You might reserve try/catch for exceptional scenarios that are truly unexpected (like catastrophic failures) while using Either for recoverable errors or expected failure scenarios within your domain.

Mixing both approaches can offer a balance between FP's explicit error handling and traditional exception-based handling, but maintaining a clear boundary between the two is crucial for clarity and consistency in your codebase.

Remember, finding the right balance between explicitness in error handling and practicality in code maintenance is key. Each project may have different requirements and trade-offs, so adapting your error-handling strategy accordingly is a good practice.