Enhancing TypeScript: Implementing Robust Error Handling with Result and Option

brunoalmeidakotesky

Bruno Kotesky

Posted on July 18, 2023

Enhancing TypeScript: Implementing Robust Error Handling with Result and Option

In my recent TypeScript projects, I've been exploring new techniques to improve the way I handle errors and deal with potential null and undefined values, and two types that have stood out in this exploration was the Rust programming language Result<T,E> and Option<T> types.

While these types originate from Rust (and many other functional programming languages too), this article focuses solely on their implementation and usage in TypeScript. You don't need any prior experience with Rust or functional programming concepts to benefit from these implementation.

So, what exactly is a Result and an Option?

In TypeScript, enums are simply collections of related values, but they are very limited and flawed in some manners, while in Rust they are way more powerful and can do so much more, like holding additional data, and other kind of stuff, like how they act way more like algebraic data types and can be used for more complex data structures.

Lets see how Rust implements Option enum:

enum Option<T> {
  Some(T),
  None,
}
Enter fullscreen mode Exit fullscreen mode

It's just as simple as that, the Some(T) variant represents the presence of a value and T type/value is wrapped inside the Some.
On the other hand, we have the None variant, which on Rust represents the absence of an value, however, it's important to note that None is not equivalent to null like other languages.

In many languages (JavaScript itself), null is a value that variables of any type can take, and as people already know, this can lead to null pointer exceptions if the programmer forgets to handle the case where a variable might be null.

In contrast, None in Rust is not a value but a variant of the Option type. If a function returns an Option, the programmer must explicitly handle the case where the result might be None. This forces the programmer to consider the possibility of absence before they can use the value, making the code safer and more predictable.

We won't have the None variant exactly like that, because of how enum values work on Rust and using null would be pointless, but we will dive on that later.

Now that we've explored the Option type, let's turn our attention to the Result type. In Rust, the Result type is another kind of enum that is used for error handling:

enum Result<T, E> {
  Ok(T),
  Err(E),
}
Enter fullscreen mode Exit fullscreen mode

The Result type has two variants: Ok and Err. The Ok variant represents a successful operation and contains a value of type T just like Some. The Err variant represents a failed operation and contains an error of type E.

Just like with Option, the Result type forces explicit error handling. If a function returns a Result, the programmer must handle both the Ok and Err cases. This makes the code more robust and prevents unexpected runtime errors.

Implementation

Okay, we've talked a lot so far, let's start with the implementations!

First of, i would like just to consider first that i'll be using an approach with functions and not classes, but it can be easily adapted, i'll show how "simple" that can be later.

Let's start by creating the types for the Option<T>, we will have seven properties on both types SomeType and NoneType, the type property well be used to indicate which variant is being used, and the value property will be used to store the value, we will also have the unwrap, unwrapOr and unwrapOrElse to safely get the values if it exists or not.

export interface SomeType<T> {
    type: 'some';
    value: T;
    /*** Returns the value of the Option if it exists, otherwise throws an error.*/
    unwrap(): T;
    /*** Returns the value of the Option if it exists, otherwise returns the provided default value.*/
    unwrapOr(defaultValue: T): T;
    /*** Returns the value of the Option if it exists, otherwise calls the provided function and returns its result.*/
    unwrapOrElse(fn: () => T): T;
    /*** Returns true if the Option contains a value, false otherwise.*/
    isSome(this: Option<T>): this is SomeType<T>;
    /*** Returns true if the Option does not contain a value, false otherwise.*/
    isNone(this: Option<T>): this is NoneType;
}

export interface NoneType {
    type: 'none';
    /*** Throws an error because None does not contain a value.*/
    unwrap(): never;
    /*** Returns the provided default value because None does not contain a value.*/
    unwrapOr<T>(defaultValue: T): T;
    /*** Calls the provided function and returns its result because None does not contain a value.*/
    unwrapOrElse<T>(fn: () => T): T;
    /*** Returns true if the Option contains a value, false otherwise.*/
    isSome<T>(this: Option<T>): this is SomeType<T>;
    /*** Returns true if the Option does not contain a value, false otherwise.*/
    isNone<T>(this: Option<T>): this is NoneType;
}

export type Option<T> = SomeType<T> | NoneType
Enter fullscreen mode Exit fullscreen mode

Note that we're using an interface instead of a type keyword because we cannot use the this (Which would be the object itself) keyword on type declarations, and we need it to be able to use the isSome and isNone methods.

These methods use "type guards" instead of a boolean return directly, because they not only check the condition but also provide type information to TypeScript, a type guard is a function that returns a boolean and narrows down the type within a conditional block, so TypeScript will be smart enough to understand that if a certain condition is true, the type of the object must be more specific than what it was outside of the condition.

Now let's create the implementations

/**
 * Creates an Option with a value.
 * @param value The value to be wrapped in the Option.
 * @returns An Option with the 'some' type and the provided value.
 */
export function Some<T>(value: T): Option<T> {
    return {
        type: 'some',
        value,
        unwrap: () => value,
        unwrapOr: () => value,
        unwrapOrElse: () => value,
        isSome: () => true,
        isNone: () => false
    };
}
/**
 * Represents an empty Option with no value.
 * @returns An Option with the 'none' type.
 */
export const None: Option<never> = {
    type: 'none',
    unwrap: () => { throw new Error('Cannot unwrap None'); },
    unwrapOr: <T>(defaultValue: T) => defaultValue,
    unwrapOrElse: <T>(fn: () => T) => fn(),
    isSome: () => false,
    isNone: () => true
};
//Freezing the None object to prevent any changes.
Object.freeze(None);
Enter fullscreen mode Exit fullscreen mode

As you can see, the implementation is very straightforward and simple, we just need to return an object with the correct properties and types, and we're done. The value property in Some is not really necessary, since you can use the unwrap function, but it's useful to have it for some cases.

The None variant, different from Rust which is just a value, is an object with the same properties as Some,
we have to create like this since the methods like unwrapOr and unwrapOrElse would still be very useful, and the main objective is avoid null/undefined.

If you still prefer an class based approach, you it would look like this, but please note that is clearly way more verbose and unnecessary, but it's still possible to do it, but problems like serialization and other stuff would be a pain to deal with.

export class Option<T> {
    readonly type: 'some' | 'none';
    constructor(type: 'some' | 'none') {
        this.type = type;
    }
    unwrap(): T | never {
        throw new Error('Must be implemented by subclasses');
    }
    unwrapOr(defaultValue: T): T {
        return defaultValue;
    }
    unwrapOrElse(fn: () => T): T {
        return fn();
    }
    isSome(): this is Some<T> {
        return this.type === 'some';
    }
    isNone(): this is None {
        return this.type === 'none';
    }
}

export class Some<T> extends Option<T> {
    private value: T;
    constructor(value: T) {
        super('some');
        this.value = value;
    }
    unwrap(): T {
        return this.value;
    }
}

export class None extends Option<never> {
    constructor() {
        super('none');
    }
    unwrap(): never {
        throw new Error('Cannot unwrap None');
    }
}
Enter fullscreen mode Exit fullscreen mode

Now let's see the Result<T, E>, we'll be having a Left and Right type, which on algebraic types, the Left represents an failed operation and right the successful path.

/** Represents a failed computation.*/
export interface Left<T, E> {
    type: 'error';
    error: E;
    /*** Returns the value of the Result if it is successful, otherwise throws an error.*/
    unwrap(): T;
    /*** Returns the value of the Result if it is successful, otherwise returns the provided default value.*/
    unwrapOr(defaultValue: T): T;
    /*** Returns the value of the Result if it is successful, otherwise calls the provided function with the error and returns its result.*/
    unwrapOrElse(fn: (error: E) => T): T;
    /*** Returns true if the Result is an error, false otherwise.*/
    isErr(this: Result<T, E>): this is Left<T, E>;
    /*** Returns true if the Result is successful, false otherwise.*/
    isOk(this: Result<T, E>): this is Right<T, E>;
}

/** Represents a successful computation.*/
export interface Right<T, E> {
    type: 'ok';
    value: T;
    /*** Returns the value of the Result.*/
    unwrap(): T;
    /*** Returns the value of the Result.*/
    unwrapOr(defaultValue: T): T;
    /*** Returns the value of the Result.*/
    unwrapOrElse(fn: (error: E) => T): T;
    /*** Returns true if the Result is an error, false otherwise.*/
    isErr(this: Result<T, E>): this is Left<T, E>;
    /*** Returns true if the Result is successful, false otherwise.*/
    isOk(this: Result<T, E>): this is Right<T, E>;
}
Enter fullscreen mode Exit fullscreen mode

As you can see it's exactly like the Option, the only difference is a second type parameter E which represents the error value.

/** Creates a successful Result with the given value.
 * @param value The value of the successful computation.
 * @returns A Result with the 'ok' type and the provided value.*/
export function Ok<T, E>(value: T): Result<T, E> {
    return {
        type: 'ok',
        value,
        unwrap: () => value,
        unwrapOr: () => value,
        unwrapOrElse: () => value,
        isErr: () => false,
        isOk: () => true
    };
}

/**
 * Creates a failed Result with the given error.
 * @param error The error that caused the computation to fail.
 * @returns A Result with the 'error' type and the provided error.
 */
export function Err<T, E>(error: E): Result<T, E> {
    return {
        type: 'error',
        error,
        unwrap: () => { throw error; },
        unwrapOr: (defaultValue: T) => defaultValue,
        unwrapOrElse: (fn: (error: E) => T) => fn(error),
        isErr: () => true,
        isOk: () => false
    };
}
Enter fullscreen mode Exit fullscreen mode

On functions that might fail, you can return the Err variant on failures like catch blocks, and the Ok variant on success, like try blocks. That can be simplified even more if you use @DefaultCatch, defaultCatch method/function decorators in your code, i'll be talking about that on another article if you're interested, these features and Result/Option types and functions can be downloaded as a npm package bakutils-catcher.

Usage

Now that we have the types and implementations, let's see how we can use them.

// Let's create a function that might fail
function divide(numerator: number, denominator: number): Result<number, string> {
    if (denominator === 0)
        return Err('Cannot divide by zero');
    else
        return Ok(numerator / denominator);
}

// Now let's use it
const result = divide(5, 0);

if (result.isErr())
    console.error(result.error); // Cannot divide by zero
else
    console.log(result.unwrap()); // This will never be reached, if accessed without checking the type, TypeScript will throw an error.

// We can also use unwrapOr and unwrapOrElse
console.log(result.unwrapOr(0)); // Return 0 if the result is an error.
console.log(result.unwrapOrElse((error) => {
    console.error(error);
    return 0;
})); //Do some additional stuff if the result is an error.

// Now let's see how we can use Option
function find<T>(arr: T[], predicate: (value: T) => boolean): Option<T> {
    for (const value of arr) {
        if (predicate(value)) {
            return Some(value);
        }
    }
    return None;
}

const array = [1, 2, 3, 4, 5];
const value = find(array, (x) => x > 3);

if (value.isSome())
    console.log(value.unwrap()); // 4
else
    console.log('No value found');

// We can also use unwrapOr and unwrapOrElse
console.log(value.unwrapOr(0)); // 0
console.log(value.unwrapOrElse(() => 0)); // 0
Enter fullscreen mode Exit fullscreen mode

This approach is way more powerful with combine it with libraries like ts-pattern which have an powerful match function for Pattern Matching.

Let's see a example for a React component with Result type:

import React, { useEffect, useState } from 'react';
import { match } from 'ts-pattern';
import { Result, Option, Some, None } from 'trentim-react-sdk';

type Data = {/*...*/ }
//Depending on your logic it could the opposite, like Result<Option<T>, Error>
type UserResult = Option<Result<Data, string>>;
// Assume we have a function that fetches data and returns a Result
async function fetchData(id?: number): Promise<UserResult> {
    if (!id) return None;
    else {
        try {
            //Fetch data...
            return Some(Ok({/*...*/}));
        } catch(e) {
            return Some(Err('There was an error'));
        }
    }
}

function MyComponent(props: { id?: number }) {
    const [result, setResult] = useState<UserResult>(None);

    useEffect(() => {
        fetchData(props?.id).then(setResult).catch(setResult);
    }, []);

    return match(result)
        .with({ type: 'none' }, () => <div>There is no data</div>)
        .with({ type: 'some' }, ({value}) => match(value)
            .with(({type: 'error'}), ({error}) => <div>There was an error... {error}</div>)
            .with(({type: 'ok'}), ({unwrap}) => <div>{unwrap()}</div>)
            .exhaustive()
        )
        .exhaustive()
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

In conclusion, the Result and Option types are powerful tools that can significantly enhance your TypeScript code. They encourage a more thoughtful and explicit approach to handling potential errors and null values, leading to more robust and predictable code.

These types, although borrowed from Rust and other functional programming languages, fit seamlessly into TypeScript. They can be easily implemented and utilized, as demonstrated in this article.

So, whether you're working on a small project or a large codebase, consider incorporating Result and Option types into your TypeScript toolkit. They not only help in writing code that works but also contribute to creating code that is safe, readable, and maintainable.

I really hope you liked, since it's my first article here, and I intent to post more "advanced" topics like these.

If you would like to use these types and previous mentioned methods, you can download my lightweight and zero dependcy package bakutils-catcher

💖 💪 🙅 🚩
brunoalmeidakotesky
Bruno Kotesky

Posted on July 18, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related