Robust error handling with the Result pattern

Table of Contents

A pain I’ve encountered when building out production-grade applications with Nest.js, or Node.js in general, is error handling. Try/catch works, and throwing errors inside a method is convenient - until you’re deep in application layers (controllers, services and repositories). Errors become invisible to callers, control flow becomes unpredictable, and if something goes wrong, you only find out at runtime! I learnt this the hard way when endpoints would occasionally throw a 500 Internal Server Error, simply because an error occurred deep in a service and wasn’t handled anywhere up the chain. You could argue I wouldn’t have this problem if I had a robust suite of tests, but it doesn’t fix the underlying issue of the code never forcing me to handle errors explicitly.

There had to be a better way forward, which is when I found the Result pattern!

What is the Result pattern?

It is simply a type that allows you to represent the outcome of an operation. It will either succeed or fail. Instead of throwing an error and hoping that it will be handled up the chain, or via an ExceptionFilter (for my Nest.js friends) etc, you return a value that encodes both possibilities: a success with data, or a failure with an error.

In TypeScript, we can implement this with a discriminated union.

type Result<T, E> = 
  | { success: true; value: T }
  | { success: false; error: E }

Yes, I know that libraries such as neverthrow exist, but I decided to roll my own out of simplicity. I didn’t feel like I would make use of an entire library for this, and there is always a benefit of keeping dependencies to a minimum.

Rather than constructing the Result type manually every time, I created two small helper functions, called ok() and err().

/**
 * A helper function to create a successful Result with a value of type T.
 * @param value - The value to be wrapped in a successful Result.
 * @returns - A Result object with success: true and the provided value.
*/
export function ok<T>(value: T): Result<T, never> {
	return { success: true, value }
}

/**
 * A helper function to create a failed Result with an error of type E.
 * @param error - The error to be wrapped in a failed Result.
 * @returns - A Result object with success: false and the provided error.
*/
export function err<E>(error: E): Result<never, E> {
	return { success: false, error };
}

Error catalogs

With the Result type in place, we need a consistent way to represent errors across the codebase. I took it upon myself to create a custom AppError class which extends on the underlying Error class. It’s allowed me to store a human-readable message, error code and HTTP status code for each application error. Sticking a HTTP status code into a domain error does blur the lines between the transport and application layers - though it has made mapping errors to HTTP responses at the controller level significantly cleaner.

export class AppError extends Error {
	public readonly code: string;
	public readonly description: string;
	public readonly httpStatusCode: number;
	public readonly context?: Record<string, unknown>;
	
	constructor(
		code: string,
		description: string,
		httpStatusCode: number,
		context?: Record<string, unknown>
	) {
		super(description);
		
		this.name = "AppError";
		this.code = code;
		this.description = description;
		this.httpStatusCode = httpStatusCode;
		this.context = context;
	}
}

I am not a fan of scattering new AppError() throughout the codebase either, which is why I also decided to create error “catalogs”. It’s a class with static methods which return errors specific to the domain. This has the added benefit of me being able to centralise all errors throughout my application, and makes it easier when it comes to understand all possible outcomes of a specific feature. Let’s take a user feature for example and an error you would expect to see throughout.

export const UserErrorCodes = {
	USER_NOT_FOUND: "USER_NOT_FOUND",
	...
} as const;
  
export class UserErrors {
	/**
	* Returns a USER_NOT_FOUND error, supplied optionally with the
	* attempted ID used to lookup, for additional context.
	*/
	static userNotFound(id?: string) {
		return new AppError(
			UserErrorCodes.USER_NOT_FOUND,
			"User does not exist.",
			404,
			{ id }
		)
	}
	
	...
}

Handling errors

We’ve got the building blocks in place for handling errors - the Result type, a custom AppError, and a catalog of User errors. How can we tie these together throughout our service and repository layers?

It all starts at the repository layer, where we are querying for our user. Instead of throwing an error when the user is not found, we return a Result instead.

@Injectable()
export class UserRepository {
	constructor(@Inject("DATABASE") private readonly _db: Kysely<DB>) {}
	
	async findById(id: string): Promise<Result<User, AppError>> {
		const user = await this._db
			.selectFrom("users")
			.selectAll()
			.where("id", "=", id)
			.executeTakeFirst();
			
		if (!user) {
			return err(UserErrors.userNotFound());
		}
		
		return ok(user);
	}
}

As you can see, the return type of Result<User, AppError> is doing the hard work. The caller (our service) immediately knows if this operation has failed, with no need to dig deeper into the implementation. Moving up to the service layer, we can “unwrap” the Result and determine what to do with it.

@Injectable()
export class UserService {
	constructor(private readonly userRepository: UserRepository) {}
	
	async getUser(id: string): Promise<Result<User, AppError>> {
		const result = await this.userRepository.findById(id);
		
		if (!result.success) {
			return err(result.error);
		}
		
		return ok(result.value);
	}
}

The service checks the Result, and if it failed, it propagates the error up the chain. If it was a success, the value is returned as usual. At no point is anything thrown - the error is just a value being passed around explicitly.

All that is left to do now is handle the service Result within a controller. All we do here is map the httpStatusCode of the AppError to a HTTP response.

@Controller("users")
export class UserController {
	constructor(private readonly userService: UserService) {}
	
	@Get(":id")
	async getUser(@Param("id") id: string) {
		const result = await this.userService.getUser(id);
		
		if (!result.success) {
			throw new HttpException(
				result.error.description,
				result.error.httpStatusCode
			);
		}
		
		return result.value;
	}
}

The controller is the only point throughout the different layers where we throw - this keeps it intentional. By handling HTTP errors at the controller level, this means the underlying errors are completely agnostic. They can be called from a REST controller (like this one), GraphQL resolver, or a queue consumer. Everything is just a Result, there is no tight coupling or hidden control flows.

My honest thoughts

Converting an entire codebase to use the Result pattern is no small task. Every method that previously threw, or that didn’t handle errors at all, now needs to return a Result and have all of its callers updated to handle that. It’s a significant undertaking if you try and do it all at once, and it’s time that a development team likely doesn’t have, especially if you’re working with an established codebase.

The approach which worked well for me was to focus on a small feature set/module to work on. Migrate that to work with the Result pattern and the rest can be done over time. When a developer touches an area of the codebase that hasn’t been migrated yet, this is a natural opportunity to convert the code they’re working on. Over time you will notice the pattern taking hold organically, without this mass migration.

The pattern is very verbose, but that is the point. It is explicit. You always know what can fail.

For a small API with a handful of endpoints, it might feel overkill to implement this pattern. But as I’ve been working with many endpoints containing complicated business logic and even calls out to task queues, treating errors as values rather than invisible exceptions has proven invaluable. I am glad that I spent the time implementing it.