If you're like most developers, you probably code a multi-step process something like this.
function square(num) {
if (typeof num !== 'number') {
throw new Error("Input must be a number");
}
return num * num;
}
function multiplyByTwo(num) {
if (typeof num !== 'number') {
throw new Error("Input must be a number");
}
return num * 2;
}
function processNumber(num) {
try {
const squared = square(num);
const doubled = multiplyByTwo(squared);
return doubled;
} catch (error) {
console.error("An error occurred:", error.message);
// Handling the error, possibly by returning a default value or further error handling
return null;
}
}
// Example usage:
const result = processNumber(42);
if (result !== null) {
console.log(result); // Output will be 1764 if no errors
} else {
console.log("Processing failed due to an error.");
}
// Another usage with an invalid input:
const badResult = processNumber('hello');
if (badResult !== null) {
console.log(badResult);
} else {
console.log("Processing failed due to an error.");
}
There isn't anything especially wrong with this approach. Exceptions are being handled and we ensure that we have code branches to handle undesirable outcomes failed network calls (for example) even if they are not exceptions.
Great!
Yet it goes nearly without saying that this imperative approach is prone to error, requires religious discipline when it comes to exceptions and undesirable outcomes and becomes increasingly difficult to debug as we introduce more branching conditions.
Miss one branch, neglect one try/catch
and we have a bug or an exception that brings down our entire application.
Throwing exceptions in this manner is like making a no-look pass. If you can count on your teammate, or in this case your peer code, to be ready to catch and meaningfully handle your exception then stop reading here.
Not My Problem
However, if you're like many teams and devs out there, your thrown exception is as likely as not to be handled, silently swallowed or to destroy your program at any moment.
Thrown exceptions not handled straightaway are an abdication of responsibility. In throwing exceptions, we say: “I take no responsibility for the way in which my code may fail or propagate failures throughout this codebase. It is, as they say, what it is.”
But could there be an alternative?
We know that exceptions and errors are best handled nearest to where they occur. The code that produces the error or encounters the exception has the most information about what happened. This is true even if such code is not in a position to resolve the exception or the error.
Why do we often write functions and methods that either return a value or trigger a complete shift in the program flow? Functionally, try/catch
is similar to the much maligned goto
statement: it can force the program's control flow to nearly anywhere, making that control flow needlessly difficult to follow.
How can we unify the control flow of our code while dealing sensibly with errors and exceptions?
A Modest Proposal
Nothing proposed in the following sections is new or novel. Sadly, in programming as elsewhere, the best or even better ideas languish while lesser ones flourish. So it is with explicitly returning values from functions and methods no matter what.
That's correct.
What would it be like if we abandoned entirely the practice of throwing exceptions? What would it be like if we explicitly returned types or values that indicate the success or failure of a computation?
We humbly present the Result
type.
export default class Result {
/**
* Wrapper for a value returned from a function
* @param {Any} value
*/
static ok(value) {
return new Result(true, value, null);
}
/**
* @param {String|Object} error
*/
static error(error) {
return new Result(false, null, error);
}
/**
* @param {Boolean} ok
* @param {Any} value
* @param {String|Object} error
*/
constructor(ok, value, error) {
this.ok = ok;
this.value = value;
this.error = error;
}
/**
* @returns {Boolean}
*/
isOk() {
return this.ok;
}
/**
* @returns {Boolean}
*/
isError() {
return !this.ok;
}
/**
* Applies a transformation function to the value of the result if it is ok,
* catching any errors thrown by the transformation function and returning them as a Result.error.
* @param {Function} transformFn - The function to transform the value.
* @returns {Result}
*/
map(transformFn) {
if (this.isOk()) {
try {
if (this.value instanceof Result) {
return this.value.map(transformFn);
} else {
const newValue = transformFn(this.value);
return Result.ok(newValue);
}
} catch (error) {
return Result.error(error.message);
}
} else {
return this;
}
}
/**
* @returns {Object}
*/
getValue() {
if (this.isError()) {
console.info('Cannot get the value of an error Result');
return this.error;
}
return this.value;
}
/**
* @returns {Object}
*/
getError() {
if (this.isOk()) {
console.info('Cannot get the error of a success Result');
}
return this.error;
}
}
function square(num) {
return num * num;
}
function multiplyByTwo(num) {
return num * 2;
}
const res = Result.ok(42).map(square).map(multiplyByTwo);
console.log(res); // { ok: true, value: 3528, error: null }
As its name suggests, this is a type that is designed to contain the result of any computation. It may contain a string, a number, an object or some other type.
To consuming code, this matters less though. Why? Because we have a specific type with a specific API that is returned from all of our method calls.
An immediate advantage is that the Result
type increases the type safety of our code. With a single type to communicate the outcome of an operation, there is less type variety to understand and manage.
A second advantage is that consumers of our Result
type are relieved of the burden of determining whether an operation is successful. The consumer simply inquires of the Result
: “Did everything go all right?”
// Checking the Result's success and extracting the value
const result = someFunction();
if (result.isOk()) {
console.log(result.getValue());
} else {
console.error(result.getError());
}
Note at this point that it doesn't matter why things didn't go right, if indeed there was a problem. We only need to make a decision about whether it is prudent to continue processing.
If all is well, we continue. Otherwise, in most cases we will return the Result
type further up the call stack. At some point, a downstream consumer of our result may be interested in what occurred or what failed to occur. This is where the Result.error
property comes in.
This property allows us, through the Result.getError
method, to discover what happened and to react accordingly.
Maybe we need to process an error code. Maybe it's simply a matter of capturing a log message. Maybe we need to return an appropriate HTTP status code. Our Result
type allows us to contain potentially volatile computations without getting mired in the implementation details.
Refusing to Throw Yields Great Results
The power of the Result
pattern is that it reduces the number of code paths the control flow of the program might use to escape. There is only one way in and one way out. This makes reasoning about and debugging our code easier--there are fewer branches to follow and less ground to cover.
By simply refusing to throw exceptions except under truly exceptional conditions, as opposed to those that are merely undesirable, we significantly improve the readability and the comprehensibility of our code. We have tighter control over our program's flow and we know exactly where errors, exceptions and computational outputs will emerge.
All of these practices put us in an excellent position to write high quality code. However, we are still left with an approach that is accompanied by an obvious shortcoming. Can you see what it is?
Further, the use of our Result
type as shown above, may introduce some additional tedium in working with our new type.
We will explore the key disadvantage of the current implementation of Result
as well as a potential solution in the next post.