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:

  1. 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);
}
}
  1. Optional handling: Handling errors and exceptions is not mandatory, so they are easy to overlook.
  2. 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 handled

Node.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 either Ok(T) or Err(E), requiring the caller to handle it explicitly.
  • The ? operator: Syntactic sugar that automatically returns an error to the caller when it encounters Err, 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 fromNullable
const 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 response
interface User {
id: number;
name: string;
email?: string;
}
const getUser = (): User | null => {
return { id: 1, name: "John", email: "[email protected]" };
};
const userEmail = Maybe.fromNullable(getUser())
.map(user => user.email)
.flatMap(email => Maybe.fromNullable(email))
.map(email => email.toLowerCase())
.getOrElse("[email protected]");
console.log(userEmail); // [email protected]

Further Reading