Atomic symbolTypeScript.WTF

The Result Type

Twitter@_charliewilco

One pattern that I'm seeing a lot in React components is how they approach data fetching in the absence of a solution like SWR or React Query. Without a prescribed pattern like that you end up with something like this:

function MyComponent() {
	const [isLoading, setLoading] = useState(true);
	const [error, setError] = useState<Error | null>(null);
	const [data, setData] = useState<DataType | null>(null);

	useEffect(() => {
		async function getData() {
			setLoading(true);

			try {
				const data = await fetchData();
				setData(data);
			} catch (err) {
				if (err) {
					setError(new Error(err as any));
				}
			}

			setLoading(false);
		}

		getData();
	}, []);
}

We're managing several distinct items, such as error, data, and isLoading, but not in an ideal way. These three have no connection to each other. We also have unanswered questions:

  • How can we restart fetching data?
  • How do caching and invalidating the cache fit in?
  • Is it possible to have both data and an error simultaneously?

I'd say state isn't part of this picture. Out of the three state hooks, data and error are not state; they're the outcome of a Promise being fulfilled. I'd even go so far as to say isLoading isn't really state either. It's the presence or absence of the Promise being resolved.

This leads us to a new pattern: the Result<T> type.

Result

The Result type is a powerful tool for handling errors in a functional and composable manner. It was first introduced in the programming language Rust, and has since been adopted by many other languages, including TypeScript.

Result is an algebraic data type that can be either Ok(T) or Err(E). The Ok variant represents a successful computation that returns a value of type T, while Err represents an error with a value of type E. This allows developers to handle errors in a consistent and predictable way, without relying on exceptions or null/undefined values.

Result is especially useful in TypeScript codebases. It allows for clear and explicit error handling. Instead of using try-catch statements or if-else statements, developers can use Result to propagate errors in a functional way. This makes the code easier to read and reason about.

type Result<T, E = string> = { data: T; ok: true } | { error: E; ok: false };

One of the benefits of the Result type is its ability to be easily composed with other Result values. By using higher order functions like map, and_then and or_else, developers can chain together a series of computations that return Result values and handle errors at any point in the chain.

Another benefit of the Result type is that it allows developers to handle errors in a way that is consistent with the rest of their code. Instead of using special error-handling constructs, like try-catch statements, developers can use the Result type to handle errors in the same way they handle other values. This can make the code more readable and easier to reason about.

Here is a very good implementation of the full concept of Result in TypeScript: vultix/ts-results.

Unwrapping a Result

In addition to the benefits of the Result type discussed above, it is also important to understand the use of the wrap and unwrap functions. These functions are used to convert between the Result type and other types in your codebase.

The wrap function is used to convert a value of type T into a Result object with the ok property set to true. This is useful when you have a computation that may fail, but you want to return a consistent Result type instead of using try-catch statements or throwing exceptions.

The unwrap function is used to extract the value of type T from a Result object. If the ok property is true, the function will return the data property. If the ok property is false, the function will throw an error with the value of the error property. This function is used to handle errors in a consistent and predictable way, and it also allows developers to avoid null/undefined values or exceptions.

It's important to note that, when using the unwrap function, it's better to handle the error case in a more explicit way, such as using the map_err method of the Result type to handle the error case in a specific way.

function wrap<T, E = string>(data: T): Result<T, E> {
	return { data, ok: true };
}

function unwrap<T, E = string>(result: Result<T, E>): T {
	if (result.ok) {
		return result.data;
	} else {
		throw new Error(result.error);
	}
}

The wrap and unwrap functions are used to convert between the Result type and other types in your codebase. The wrap function is used to convert a value of type T into a Result object, while the unwrap function is used to extract the value of type T from a Result object and handle errors in a consistent and predictable way. It's important to use them in conjunction with other methods of the Result type to handle the error case explicitly.

Using Result in your Data Fetching

Back to our React example we can simplify our data fetching with a the Result type. Let's create a custom hook to handle our data fetching.

And by simplify, I mean we're going to make it way more complicated.

import { useEffect, useState, useRef, useCallback } from "react";

type Result<T, E = string> = { data: T; ok: true } | { error: E; ok: false };

function useAsyncResult<T = unknown>(getPromise: () => Promise<T>) {
	const [, forceUpdate] = useState(0);
	const result = useRef<Result<T> | null>(null);
	const hasFired = useRef(false);

	const firePromise = useCallback(async () => {
		hasFired.current = true;
		if (result.current) {
			return forceUpdate(Date.now());
		}

		try {
			const data = await getPromise();
			result.current = wrap(data);
		} catch (error) {
			result.current = {
				ok: false,
				error: new Error(error),
			};
		} finally {
			forceUpdate(Date.now());
		}
	}, [getPromise, forceUpdate]);

	useEffect(() => {
		if (!hasFired.current && result === null) {
			firePromise();
		}
	}, [result, firePromise]);

	return {
		isLoading: result.current === null,
		get data() {
			if (result.current?.ok) {
				return result.current.data as T;
			}
		},
		get error() {
			if (!result.current?.ok) {
				return result.current?.error;
			}
		},
	};
}

This probably looks like not a simplification at all. Now our implementation looks like this:

function MyComponent() {
	const { isLoading, data, error } = useAsyncResult(getData);
}

The useAsyncResult hook takes a single argument getPromise, which is a function that returns a promise. It returns an object with three properties:

  • isLoading: a boolean that indicates whether the promise is still pending and the result is not available yet.
  • data: the data returned by the promise if it was resolved successfully, or undefined otherwise.
  • error: an error object if the promise was rejected, or undefined otherwise.

The hook uses the useState, useRef, useCallback, and useEffect hooks to manage the state of the asynchronous operation. The useState hook is used to store a dummy value that gets updated to trigger a re-render when the state changes. The useRef hook is used to store the result of the promise or any error that might occur during the promise execution. It's used here because we want to keep the state between re-renders. The useCallback hook is used to create a memoized version of the function that will execute the promise. It prevents unnecessary re-renders by ensuring that the same function reference is used across re-renders. The useEffect hook is used to call the firePromise function only once, on the first render, and to update the hasFired and result refs accordingly. The firePromise function executes the getPromise function and stores the result or the error in the result ref. It also updates the hasFired ref and triggers a re-render. Finally, the hook returns an object with three properties that can be used to conditionally render components based on the state of the promise.

This while being a little more code, gives this return object a relationship with each other through a Result and our implementation handles much more use cases and prevents unnecessary re-rendering and we've made our useState hook responsible for re-rendering and having our component read the Result.


In summary, the Result type is a powerful tool for handling errors in a functional and composable way. It was first introduced in Rust and has since been adopted by many other languages, including TypeScript. It is especially useful in a TypeScript codebase as it allows for clear and explicit error handling, easy composition and consistency in handling errors with other values.