I'm delving into functional programming and exploring different ways to handle errors beyond the traditional try/catch method. One concept that has caught my attention is the Either monad in various programming languages. I've been experimenting with applying this concept to a simple app built using Express and fp-ts.
Imagine a scenario where we have the following request handling architecture (let's say it's for retrieving an entity from the database):
Express -> route handler -> controller -> repository -> data source -> database
This setup opens up multiple points where errors could potentially occur. Let's start by examining the data source. To allow flexibility in switching data sources in the future, I initially defined my data source interface as follows:
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>;
};
The repository factory function takes this data source as an argument during creation:
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),
};
}
Similarly, the repository implementation conforms to 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>;
};
As I attempt to integrate the use of Either
s, I find that my interfaces become closely intertwined and verbose.
Initially, I update the data source to utilize Either
as its 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>>;
};
Modifying the repository interface to accommodate Either
:
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>>;
};
The repository and data source are becoming interconnected through their error types, but the repository introduces additional error types like PARSE_ERROR
and TODO_NOT_FOUND
. While this may not be problematic at this stage, if the call chain becomes longer with multiple services and repositories, managing all these error types within the interfaces could get cumbersome.
Is there a better way to approach this? Or is this complexity expected?
One alternative could be combining both approaches, using try/catch
for exceptions and delegating error handling to the controller. Utilizing Either
earlier in the call stack to manage errors effectively.