Enhancing TypeScript: Implementing Robust Error Handling with Result and Option
Tags: typescript, rust, programming
Published: 2023-07-18 17:27:54

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](https://en.wikipedia.org/wiki/Algebraic_data_type) and can be used for more complex data structures.

Lets see how Rust implements `Option` `enum`:
```rs
enum Option<T> {
  Some(T),
  None,
}
```
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.

<u>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.</u>

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:
```rs
enum Result<T, E> {
  Ok(T),
  Err(E),
}
```
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`](https://doc.rust-lang.org/std/option/enum.Option.html#method.unwrap), [`unwrapOr`](https://doc.rust-lang.org/std/option/enum.Option.html#method.unwrap_or) and [`unwrapOrElse`](https://doc.rust-lang.org/std/option/enum.Option.html#method.unwrap_or_else) to safely get the values if it exists or not.

```ts
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
```

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
```ts
/**
 * 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);
```

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.

```ts
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');
    }
}
```

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.

```ts
/** 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>;
}
```

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

```ts
/** 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
    };
}
```

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](https://github.com/BrunoAlmeidaKotesky/bakutils-catcher).

### Usage

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

```ts
// 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
```

This approach is way more powerful with combine it with libraries like [ts-pattern](https://github.com/gvergnaud/ts-pattern) which have an powerful `match` function for [Pattern Matching](https://doc.rust-lang.org/book/ch18-03-pattern-syntax.html).

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

```tsx
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()
}
```

### 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](https://github.com/BrunoAlmeidaKotesky/bakutils-catcher)