In this article, we will take a different perspective on the Either/Result type to explore a better approach to error handling in TypeScript. Find the best and most convenient way to handle error handling for the entire code base.
A consistent error handling approach is about making a choice for error handling and adhering to it consistently throughout your codebase. Common strategies include using exceptions, Result/Either types, or custom error objects.
First of all, let’s focus on the Either/Result type. We will discuss what the Either/Result type is and how we can achieve it in TypeScript. If we look at Haskell, the Either type represents values with two possibilities: a value of type Either a b can be either Left a or Right b. If you’re coming from Rust, Result<T, E>
serves as the type for handling and passing along errors. It takes the form of an enum with two variants: Ok(T)
, signifying a successful result and holding a value, and Err(E)
, indicating an error condition and containing an error value
When we discuss the Either type in TypeScript, the fp-ts library empowers developers to build pure functional programming apps, using key abstractions from languages like Haskell, PureScript, and Scala.
type Either<E, A> = Left<E> | Right<A>
Now, let’s look at a simple example. When we send a request and process data based on the server’s response.
import * as P from 'fp-ts/lib/pipeable';
import * as E from 'fp-ts/lib/Either';
import axios, { AxiosResponse, AxiosError } from 'axios';
// Custom error type
type ResponseError = string;
async function makeApiCall(): Promise<E.Either<ResponseError, AxiosResponse>> {
try {
const response = await axios.get('https://jsonplaceholder.typicode.com/posts/1');
return E.right(response);
} catch (error) {
const axiosError = error as AxiosError;
return E.left(`API Error: ${axiosError.response?.statusText || 'Unknown'}`);
}
}
const post = makeApiCall()
.then((apiResponse) => {
P.pipe(
apiResponse,
E.match(
(error: ResponseError) => {
console.error(error);
return null
},
(response: AxiosResponse) => response.data
)
);
});
As we can see, the response of makeApiCall will have a type Either<ResponseError, AxiosResponse>
, which protects us from any undefined error behavior. fp-ts
is a powerful library, and I prefer using it over Ramda
or Lodash/fp
. Also EffectTS implementing some fp-ts concepts.
But what about situations where you cannot introduce a new dependency into your project and need to find the best solution for it? I must say, I love the Rust programming language. It introduces the Result type, from which we can extract a minimal subset to create our version of the Either type.
/**
* Represents a custom type that can hold either a successful result or an error.
* It is defined as a tuple with two optional elements, T for success and E for error.
*/
export type Result<T, E> = [T | null, E | null];
/**
* Creates a new `Result` object representing an error condition (`Err`).
*
* The function takes an `Error` object and constructs a `Result` tuple
* with a `null` value for success and the provided error value.
*/
export const Err = <T extends Error>(error: T): Result<null, T> => [null, error];
/**
* Creates a new `Result` object representing a successful outcome (`Ok`).
*
* The function takes a value of type `T` (or `null`) and constructs a `Result` tuple
* with the provided value for success.
*/
export const Ok = <T>(result: T | null): Result<T, null> => [result, null];
/**
* Checks if the given `Result` object represents an error (`Err`).
*
* The function examines the `Result` tuple and determines if it represents an error case.
* It returns a boolean value indicating whether the `Result` is an error.
*/
export const isErr = <T, E>(result: Result<T, E>): result is [null, E] =>
result[0] === null && result[1] !== null;
/**
* Checks if the given `Result` object represents a successful result (`Ok`).
*
* The function examines the `Result` tuple and determines if it represents a successful outcome.
* It returns a boolean value indicating whether the `Result` is a success.
*/
export const isOk = <T, E>(result: Result<T, E>): result is [T, null] =>
result[0] !== null && result[1] === null;
/**
* Matches the result of a `Result` type and applies the appropriate callback function.
* If the `Result` is `Ok`, the `onSuccess` callback is called with the value of type `T`.
* If the `Result` is `Err`, the `onError` callback is called with the error value of type `E`.
*/
export const match = <T, E, R>(
result: Result<T, E>,
onSuccess: (value: T) => R,
onError: (error: E) => null | unknown
): R => {
if (isOk(result)) {
return onSuccess(result[0]!);
} else {
return onError(result[1]!);
}
};
Now, let’s explore an example, similar to what we previously discussed with fp-ts, but this time, we’ll utilize a custom Result type.
import axios, { AxiosResponse } from 'axios';
import Result, Ok, Err from 'result';
// Create an API request function that returns a Result
async function makeApiCall(): Promise<Result<AxiosResponse, Error>> {
try {
const response = await axios.get('https://jsonplaceholder.typicode.com/posts/1');
return Ok(response);
} catch (error) {
return Err(error as Error);
}
}
// Use the makeApiCall function and match the result
const apiResult = await makeApiCall();
const post = match(
apiResult,
(response) => {
// Handle successful result
return response.data;
},
(error) => {
return `Error: ${error}`;
}
);
When we look at the same concept in Rust, it appears quite similar, doesn’t it?
use reqwest;
use std::error::Error;
use std::result::Result;
async fn make_api_call() -> Result<String, Box<dyn Error>> {
match reqwest::get("https://jsonplaceholder.typicode.com/posts/1").await {
Ok(response) => match response.text().await {
Ok(data) => Ok(data),
Err(err) => Err(Box::new(err)),
},
Err(err) => Err(Box::new(err)),
}
}
#[tokio::main]
async fn main() {
let post = make_api_call().await;
dbg!(&post);
}
It’s not identical, but it can be very close to what we’ve discussed.
However, when you consider TypeScript, the package fp-ts brings functional programming features to the table. In any case, you can also create your custom version of Either/Result, just as I did.
Now, let’s go back and look at the common strategies, which include using exceptions, Result/Either types, or custom error objects.
import axios, { AxiosError } from 'axios';
import { Either, left, right, isLeft } from 'fp-ts/lib/Either';
type CustomError = {
type: 'CustomError';
message: string;
};
type NetworkError = {
type: 'NetworkError';
message: string;
};
type AppError = CustomError | NetworkError;
type Result<T> = Either<AppError, T>;
async function makeApiCall(): Promise<Result<any>> {
try {
const response = await axios.get('https://jsonplaceholder.typicode.com/posts/1');
return right(response.data);
} catch (error) {
if (axios.isAxiosError(error)) {
return left<NetworkError>({ type: 'NetworkError', message: error.message });
} else {
return left<CustomError>({ type: 'CustomError', message: 'Something went wrong.' });
}
}
}
const apiResult = await makeApiCall();
const output = isLeft(apiResult)
? `Error: ${apiResult.left.message}`
: `Data received: ${apiResult.right}`;
I trust that these insights will enhance your error handling practices. However, it’s essential to pay close attention to the following considerations. Creating error objects at the module level provides more detailed information for code debugging. Combining Either type objects enables you to handle situations where positive or negative results might be overlooked. Additionally, it’s crucial to maintain the correct context for your error messages and make use of standard error library (ReferenceError, TypeError, etc) to enhance the clarity of your code. Also, remember to consider the varying levels of error logging based on usage, such as using plain text for development and a structured JSON format for production.
⚖️ © Licensed under Creative Commons Attribution 4.0 International (CC BY 4.0).
Top comments (0)