If you have a function that might fail, you would probably do something like this.
let result;
try {
const user = unsafe();
result = user.name;
catch(e) {
result = null;
}This very common implementation has a bug though. It handles all exceptions, not just the ones we expect to happen during normal operation.
If usafe has a Syntax error in it's implementation this would silently swallow it. We don't want that.
A better implementation would be to throw custom error-types for all your expected exceptions and test anything that's thrown against those.
class CustomException1 extends Error {}
class CustomException2 extends Error {}
let result;
try {
const user = unsafe(); //throws CustomException1 & 2
result = user.name;
catch(e) {
if(e instanceof CustomException1) result = null;
else if (e instanceof CustomException2) {
console.warn("CustomeException2");
result = null;
}
else throw e;
}But the code here gets really really ugly really really fast. We have to imperatively check which Execution path we should take, opening up the door to many silly bugs.
Wouldn't it be really nice if we could declaratively define each execution path an the right thing just happened?
Other languages like Rust would make this pretty easy using Errors-As-Values and match statements. Something like this:
let result = match unsafe_fn() {
Ok(user) => user.name,
Err(CustomExceptions::1) => null,
Err(CustomExceptions::2) => {
print!("CustomeException2");
null;
},
Err(error) => panic!("Unexpected Error: {:?}", error),
};This way we can declaratively define each possible execution branch, drastically reducing the chance of bugs.
I took a stab at implementing a similar API in JS, and I came up with the ResultMatcher class (snippet below).
You can use it like this:
const result = ResultMatcher(unsafe)
.ok(user => user.name)
.catch(CustomException1, e => null)
.catch(CustomException2, e => { console.warn("CustomException2"); return null})
.run()It is fully typesafe making it a breeze to work with. Let's take a look at each part:
const resultwill be the return value of whatever execution branch is taken. In the snippet above the return type would bestring | nullResultMatcher(unsafe)constructs a matcher instance for the functionunsafe.ok()takes a callback that handles the return value ofunsafeif it succeeds. If.okis not used on the Matcher it will default to the identity function..catch(CustomException1, e => null)Will only run ifunsafethrows aCustomException1. It may return a value..run()Actually callsunsafe. If unsafe takes args, you will pass them here (Eg:run("Hello", {option: "a"})). TS will enforce this.
Sometimes you do want to react to all errors that are thrown. Maybe just to log them. For that we have the catchAll method.
.catchAll(e => {console.error(e); throw e})