Programming with exceptions is difficult and inelegant. Learn how to handle errors better by representing them as values.
In Smelly Exceptions, I laid out 3 practices to avoid when programming with exceptions. My goal there was to help you write more maintainable code. We saw that misuse of exceptions make programs brittle, tightly coupled, and difficult to reason about. The common factor in all three anti-patterns we discussed was exceptions. They are hard to use correctly. Even when used correctly, they don't compose, so the program flow is unnatural. In short, they are exceptions.
Generally, using exceptions for control flow is like calling GOTO, but without specifying where to go. Exceptions create implicit paths through your program, from every operation that can throw back through every exception handler on the call stack. Reasoning about all those paths is hard enough. It is best to limit them to states in which your program cannot return reasonable result. That is what exceptions are for. humanlytyped.hashnode.dev/smelly-exceptions
What if we don't have to use exceptions to signal errors? Well, that's the case. Errors are of different kinds. Some errors are unexpected and should stop the program; you want to use exceptions for those. Others are expected, so we should handle them just as we handle normal program input. Handle expected errors as values.
What "errors as values" mean
When we say that errors are values, it may seem we are stating the obvious. Of course, when you write throw new Error("an error message")
, you are throwing a value. What you pass to the throw
construct is a value. Obviously. But that's not the full story. When we say that errors are values, we mean that they should be treated as any other value in your program rather than as signals. The language's exception handling/propagation mechanism runs specially through the program, taking a different control flow path that only goes backwards as it unwinds the stack. That's the implicit path we alluded to. We want to avoid that path entirely. We don't want to throw exceptions when we encounter errors that are expected.
Expected errors vs unexpected errors
Suppose you are building a web server to serve content from a database. A user may request a record that doesn't exist. We expect that to happen—users make typos, and things get deleted. In this case, we should not throw an exception.
Another example of a case where we should not throw an exception is for validation errors. We expected the user to enter a number between 50 and 1000, but they entered 5? No problem, just tell them that they entered a number that's outside the valid range. In fact, the Joi validation library gets this right. It doesn't throw errors when validation fails, it returns the validation error messages.
Only throw exceptions when something really bad has happened and the program must stop. For example:
- the program cannot connect to its database;
- the program cannot write output to disk because the disk is full;
- the program was not started with valid configuration.
In any of these cases, your best bet is to log the exception and report the problem, then quit.
Coding with errors as values
Now we know what errors are expected and those that are unexpected. We know that we want to use the exception mechanism for only unexpected errors that put the program in invalid states. How can we handle the expected errors without throwing?
There are two common ways of using errors as values. The first is to return multiple results from each function that can product an error; the other is to return a single value that represents either a success or an error.
Multiple return values
The Joi validation library has several methods to run a validation. The any.validate
method is the interesting one here. It doesn't throw when there's an error, it returns an object that contains all its results, including any error. Thus you can write a function like the following.
const { error, value } = joi.validate(data, schema, {
abortEarly: false,
stripUnknown: true
});
if (error) {
}
This approach makes the code more readable. You don't need to trace the error handling path backwards from your normal flow. Also, you can pass that value returned by joi.validate
to any function. You can have a pipeline of functions that transform the value as if the error is not there, or that use the error and ignore the value. This idea is very significant.
Imagine your error values as some kind of array. When using arrays, the empty array is like an error value: you expect some values, but there's nothing there. But that doesn't stop you from using it as you would if there were values in it. You can map over it, filter the elements (even if they're not there), and reduce it to a single value. Mapping or filtering over it still gives you your error value—the empty array. Reducing it lets you get a value if there are elements, or get a default value if it's empty. Can we build a data type that gives us these array-like capabilities for our errors? Let's try and see what the result will be 😉.
The Result
type
Let's take a clue from the Rust programming language. Its standard library has a type called Result
. It defines a class of values that represent either a success or a failure. Here's how it's defined:
enum Result<T, E> {
Ok(T),
Err(E),
}
If a function returns a Result<User, Error>
, it means that you get a User
if it succeeds, or an Error
if it fails. The magic here is that you can use it as a single value as if the error isn't there, then handle the error where you need to. If you have a result value r
, you can write r.map(|v| ...)
, using it just like an array. When you're done, you can use pattern matching (like a switch statement) to handle the error if it's there.
You can read Recoverable Errors with Result to learn more about how this works in Rust. Can we program in this style in TypeScript? Of course. Most functional programming libraries out there support this style of programming. You can take a look at fp-ts, monet, or folktale, among others. Sadly, folktale hasn't been ported to TypeScript yet, but I like its API. Take a look at folktale's Result API. You can see that it provides many advantages over programming with exceptions.
Comparing "exception for control flow" with Result
I'll finish with an example to show how using errors as values makes your programs easier to read. The example involves a little convoluted logic to show you how exceptions don't compose. The steps that the program computes are as follows.
- Parse an integer N from a string.
- If N is
NaN
, fail with an error. Otherwise, increment N by 1. - If N is > 3, fail with an error. Otherwise, increment N by 1.
- If steps 1-3 failed, set N to 3.
- Increment N by 1.
I'll show you two programs that compute this sequence of instructions; you judge which is more readable.
The first one uses a ResultType
that I defined in a module. Notice how each method call after parseInt
corresponds to a step of our algorithm.
import type { ResultType } from "./result";
import { fail, ok } from "./result";
import * as R from "ramda";
function parseInt(v: string, radix = 10): ResultType<Error, number> {
const n = Number.parseInt(v, radix);
return Number.isNaN(n) ? fail(new Error("NaN")) : ok(n);
}
const add1 = R.add(1);
const n = parseInt("a3")
.map(add1)
.then((v) => (v > 3 ? fail(new Error("gt3")) : ok(v + 1)))
.fallback(ok(3))
.map(add1)
.fold({ onSuccess: (v) => v, onError: R.always(0) });
Compare the code above with the one below that uses exceptions. If you ignore the import statements and the function definition in the one using Result
, you'll see that the program below corresponds to the const n = ...
part of the one above.
let v = Number.parseInt("a3", 10);
try {
if (Number.isNaN(v)) {
throw new Error("NaN");
} else {
v += 1;
}
if (v > 3) {
throw new Error("gt 3");
} else {
v += 1;
}
} catch (error) {
v = 3;
}
v += 1;
Which program is easier to read? Which of them communicates its intent better?
What next?
You've seen better ways of representing error values. Now you can reserve exceptions for the truly exceptional cases: for cases where you may as well let it crash. This pattern isn't exclusive to functional programming, you can use it in your object-oriented code too.
If you found this article helpful, share it with your friends and colleagues as well. Comments are welcome too, your feedback can make my next article better, and I can learn from you. Cheers.
from Hacker News https://ift.tt/3mDlXqv
No comments:
Post a Comment
Note: Only a member of this blog may post a comment.