Building Error Handling (feat. Java, Go, Rust, TS)
Introduction
Error handling is a concern in every program, especially in complex web development. This article explores different patterns and languages to examine how to build developer-friendly error handling. Recommended talk: Unexpected Monad. Is Safe Error Handling Possible in JS/TS? by Artem Kobzar and Dmitry Makhnev - JSConf.
Defining “Errors”: Exception vs. Error
- Exception: A problem a program can anticipate and handle
- A user enters an incorrect username or password
- Error: A serious problem a program cannot handle
- A system crash or an out-of-memory condition
What Are the Shortcomings of JavaScript try catch?
Most languages have a try-catch mechanism, which seems intuitive. Here is an example in JavaScript:
try { doThings()} catch(error) { console.error(error)}In practice, however, several problems arise:
- Static type analysis: When handling exceptions in weakly typed languages, the absence of an explicit type means that type checks or assertions are necessary.
try { throw new Error('Error');} catch (error) { // error could be anything, so a runtime check is needed if (error instanceof Error) { console.error(error.message); }}- Optional handling: Handling errors and exceptions is not mandatory, so they are easy to overlook.
- Exceptions and errors are hard to distinguish: Any problem gets caught, with no clear indication of where it was thrown, while splitting the code into multiple try-catch blocks hurts readability.
try { const request = { name: "test", value: 2n }; const body = JSON.stringify(request); // Could fail const response = await fetch("https://example.com", { // Could fail method: "POST", body, }); if (!response.ok) { return; } // handle response // Could also fail} catch (e) { // No way to tell which step failed return;}Learning from Error Handling in Other Languages and Ecosystems
Explicit Error Handling in Java
In Java, handling checked exceptions is explicit: you must specify how exceptions are handled in your code, or it will not even compile.
class PositiveNumber { private int value;
public PositiveNumber(int number) throws NegativeNumberException { if (number <= 0) { throw new NegativeNumberException("Number must be positive: " + number); } this.value = number; }
public int getValue() { return value; }}
PositiveNumber num = new PositiveNumber(-5); // Compilation fails because the exception is not handledNode.js Callback Style: Exceptions as Return Values
If we want to require explicit handling of exception values, why not return exceptions as part of the result? In the early JavaScript Node.js community, error-first callbacks were a convention for handling asynchronous code (before even Promise or Async Await had been introduced). By passing exceptions to a callback function, “errors” can still be thrown and caught, while “exceptions” become part of the program logic that requires explicit handling.
fs.readFile('foo.txt', (err, data) => { if (err) { // Handle the error } else { // Use data }})As program logic grows more complex, however, Callback Hell becomes inevitable, and this pattern relies on shared conventions and cooperation among developers.
Multiple Return Values in Go
Rather than using throw Error and try catch for exception and error handling, Go commonly relies on the language’s Multiple Return Values feature to return exceptions as values as well.
func main() { file, err := os.Open("File.txt") if err != nil { log.Fatal(err) } fmt.Print(file)}Some developers dislike the fact that Go has no simple exception-throwing mechanism and instead relies entirely on checking returned values:
if err != nil { … if err != nil { … if err != nil { … } }}if err != nil { …}…if err != nil { …}The Rust Result Type
Where Go typically returns both a result and an error through multiple return values, Rust uses the Result<T, E> type to explicitly return either success or failure. This mechanism is enforced and built into the language:
fn read_file() -> Result<String, std::io::Error> { let content = std::fs::read_to_string("foo.txt")?; Ok(content)}
fn main() { match read_file() { Ok(data) => println!("Contents: {}", data), Err(e) => eprintln!("Error: {}", e), }}Result<T, E>: An enum that can only be eitherOk(T)orErr(E), requiring the caller to handle it explicitly.- The
?operator: Syntactic sugar that automatically returns an error to the caller when it encountersErr, keeping the code concise while preserving explicit handling.
Here, the Result type embodies the Monad pattern: wrapping a computation in a “box” lets you safely operate on its contents without worrying about or checking whether they contain an error.
fn safe_divide(x: i32, y: i32) -> Result<i32, String> { if y == 0 { Err("divide by zero".to_string()) } else { Ok(x / y) }}
fn main() { let result = Ok(2) .map(|x| x + 4) // Ok(6) .and_then(|x| safe_divide(x, 0)) // Err("divide by zero") .map(|x| x * 2) // Skipped (because it is Err) .map(|x| x - 1) // Skipped .unwrap_or(-999); // Returns -999 as the default because of the error
println!("{:?}", result); // -999}Improving the JavaScript Error-Handling Experience
Result Type
Drawing on the “errors as return values” pattern in Go and Rust, we can quickly achieve a similar experience by defining a Result type in TypeScript and using ok and err functions to construct result objects:
type Result<T> = { success: true; value: T } | { success: false; error: Error };
function ok<T>(value: T): Result<T> { return { success: true, value };}
function err(error: Error): Result<never> { return { success: false, error };}
function hello(name: string): Result<string> { if (name === "") { return err(new Error("empty name")); }
return ok(`Hi, ${name}. Welcome!`);}
function main(): void { const result = hello('');
if (result.success) { console.log(result.value) } else { console.log(result.error) }}Maybe Monad
class Maybe { constructor(value) { this.value = value; }
static just(value) { return new Maybe(value); }
static nothing() { return new Maybe(null); }
isNothing() { return this.value === null || this.value === undefined; }
map(fn) { if (this.isNothing()) { return Maybe.nothing(); } return Maybe.just(fn(this.value)); }
flatMap(fn) { if (this.isNothing()) { return Maybe.nothing(); } return fn(this.value); }
getOrElse(defaultValue) { if (this.isNothing()) { return defaultValue; } return this.value; }}
const safeDivide = (x, y) => { if (y === 0) { return Maybe.nothing(); } return Maybe.just(x / y);};
const result1 = Maybe.just(2) .map(x => x + 4) .flatMap(x => safeDivide(x, 3)) .map(x => x * 2) .map(x => x - 1) .getOrElse("Error!");
console.log(result1); // 3
const result2 = Maybe.just(2).map(x => x + 4).flatMap(x => safeDivide(x, 0)).map(x => x * 2).map(x => x - 1).getOrElse("Error!");
console.log(result2); // Error!class Maybe<T> { private constructor(private readonly value: T | null) {}
static just<T>(value: T): Maybe<T> { return new Maybe(value); }
static nothing<T>(): Maybe<T> { return new Maybe<T>(null); }
isNothing(): boolean { return this.value === null || this.value === undefined; }
map<U>(fn: (value: T) => U): Maybe<U> { if (this.isNothing()) { return Maybe.nothing<U>(); } return Maybe.just(fn(this.value!)); }
flatMap<U>(fn: (value: T) => Maybe<U>): Maybe<U> { if (this.isNothing()) { return Maybe.nothing<U>(); } return fn(this.value!); }
getOrElse(defaultValue: T): T { if (this.isNothing()) { return defaultValue; } return this.value!; }
filter(predicate: (value: T) => boolean): Maybe<T> { if (this.isNothing() || !predicate(this.value!)) { return Maybe.nothing<T>(); } return this; }
toNullable(): T | null { return this.value; }
static fromNullable<T>(value: T | null | undefined): Maybe<T> { return value != null ? Maybe.just(value) : Maybe.nothing<T>(); }}
const safeDivide = (x: number, y: number): Maybe<number> => { if (y === 0) { return Maybe.nothing<number>(); } return Maybe.just(x / y);};
const result1 = Maybe.just(2) .map(x => x + 4) .flatMap(x => safeDivide(x, 3)) .map(x => x * 2) .map(x => x - 1) .getOrElse(-1);
console.log(result1); // 3
const result2 = Maybe.just(2) .map(x => x + 4) .flatMap(x => safeDivide(x, 0)) .map(x => x * 2) .map(x => x - 1) .getOrElse(-1);
console.log(result2); // -1
// Example using fromNullableconst parseNumber = (str: string): Maybe<number> => { const num = parseFloat(str); return isNaN(num) ? Maybe.nothing<number>() : Maybe.just(num);};
const calculation = parseNumber("10") .flatMap(x => safeDivide(x, 2)) .map(x => x + 1) .filter(x => x > 5) .getOrElse(0);
console.log(calculation); // 6
// Handle a potentially null API responseinterface User { id: number; name: string; email?: string;}
const getUser = (): User | null => {};
const userEmail = Maybe.fromNullable(getUser()) .map(user => user.email) .flatMap(email => Maybe.fromNullable(email)) .map(email => email.toLowerCase())
Further Reading
- Using JavaScript try…catch to Handle Errors in Your Programs - WebDong
- How Do You Handle Errors Thrown in TypeScript? - WebDong
- Golang style error handling pattern in Javascript - 5 Error
- An Illustrated Guide to Monads - Ruan Yifeng’s Blog
- Monad is actually easy. (Conquering the Final Boss of Functional Programming)
- I Fixed Error Handling in JavaScript - Bret Cameron