DEV Community

Gio
Gio

Posted on

Chaining Failable Tasks

originally posted on my blog

This post assumes familiarity with TypeScript.

In my previous post, Type-Safe Error Handling In TypeScript, I introduced a npm package to model failure at the type level.

If you're not familiar with neverthrow, here's a quick rundown (feel free to skip this tiny intro by clicking here):

  • The package introduces a functional alternative to throwing exceptions
    • By getting rid of throwing exceptions, you make your error handling logic pure!
    • This is the standard approach in many other languages such as Rust, Elm and Haskell to name a few. This isn't some random wild experiment I invented.
  • neverthrow has a Result type that represents either success (Ok) or failure (Err)

Result is defined as follows:

type Result<T, E> = Ok<T, E> | Err<T, E> 
Enter fullscreen mode Exit fullscreen mode

Ok<T, E>: contains the success value of type T

Err<T, E>: contains the failure value of type E

Usage:

Create Ok or Err instances with the ok and err functions.

import { ok, err } from 'neverthrow' // something awesome happend const yesss = ok(someAwesomeValue) // moments later ... const mappedYes = yesss.map(doingSuperUsefulStuff) 
Enter fullscreen mode Exit fullscreen mode

You can access the value inside of Err and Ok instances as follows:

if (myResult.isOk()) { // if I didn't first call `isOk`, I would get a compilation error myResult.value } // or accessing values if (myResult.isErr()) { myResult.error } 
Enter fullscreen mode Exit fullscreen mode

This quick rundown doesn't do the package justice, so I highly recommend you check out my previous post that really walks you through the package.

...


A while back, I got feedback (link to github issue) from two users that this module wasn't very ergonomic when it came to Results wrapped inside of a promise.

This post is dedicated to covering the problem, and the solution to it.

The Problem

Let's suppose we're working on a project that has 3 async functions:

  • getUserFromSessionId
  • getCatsByUserId
  • getCatFavoriteFoodsByCatIds

And here are the type signatures for each of these functions:

type GetUserFromSessionId = (sessionUUID: string) => Promise<Result<User, AppError>> 
Enter fullscreen mode Exit fullscreen mode
type GetCatsByUserId = (userId: number) => Promise<Result<Cat[], AppError>> 
Enter fullscreen mode Exit fullscreen mode
type GetCatFavoriteFoodsByCatIds = (catIds: number[]) => Promise<Result<Food[], AppError>> 
Enter fullscreen mode Exit fullscreen mode

Let's also assume that you're a developer tasked with leveraging these functions in order to get all of the favorite foods of all of the cats owned by a single user.

By taking a close look at the type signatures of these functions, we can start to see how we might go about implementing our task:

  • First call getUserFromSession
  • then get the User and use that value to call getCatsByUserId
  • then get all of the cats (Cat[]) and call getCatFavoriteFoodsByCatIds by passing it an array of cat ids

The issue is that the values we need (User, Cat[] and Food[]) are wrapped inside of Promise and Result.

First Attempt At A Solution

Let's see how we might implement this naively.

The neverthrow api has a asyncMap method and andThen method that we could use to solve this:

// imagine we have a sessionId already const result1 = await getUserFromSessionId(sessionId) // result2 is a Result<Result<Cat[]>, AppError>, AppError> const result2 = await result1.asyncMap((user) => getCatsByUserId(user.id)) // need to get the inner result using `andThen` // now catListResult is Result<Cat[]>, AppError> const catListResult = result2.andThen((innerResult) => innerResult) // result3 is // Result<Result<Food[], AppError>, AppError> const result3 = await catListResult.asyncMap( (cats) => getCatFavoriteFoodsByCatIds(cats.map((cat) => cat.id)) ) // so now we need to unwrap the inner result again ... // foodListResult is Result<Food[], AppError> const foodListResult = result3.andThen((innerResult => innerResult)) 
Enter fullscreen mode Exit fullscreen mode

Holy boilerplate! That was not fun. And super cumbersome! There was a lot of legwork required to continue this chain of async Result tasks.

... If there were only a better way!

Using Result Chains! 🔗

Version 2.2.0 of neverthrow introduces a wayyy better approach to dealing with this issue.

This is what it would look like

import { chain3 } from 'neverthrow' // foodListResult is Result<Food[], AppError> const foodListResult = chain3( getUserFromSessionId(sessionId), (user) => getCatsByUserId(user.id), (cats) => { const catIds = cats.map((cat) => cat.id) return getCatFavoriteFoodsByCatIds(catIds) } ) 
Enter fullscreen mode Exit fullscreen mode

That's it.

Check out the API docs here.

Obviously the above example is quite contrived, but I promise you that this has very practical implications. As an example, here's a snippet from my own side project where I use the chain3 function:

chain3( validateAdmin(parsed.username, parsed.password), async (admin) => { const sessionResult = await session.createSession(admin) return sessionResult.map((sessionToken) => { return { sessionToken, admin } }) }, ({ sessionToken, admin }) => Promise.resolve( ok(AppData.init( removePassword(admin), sessionToken )) ) ) 
Enter fullscreen mode Exit fullscreen mode

There are 8 different chain functions, each of which only vary in their arity (the number of arguments that the functions take).

  • chain: takes 2 async Result tasks
  • chain3: takes 3 async Result tasks
  • chain4: takes 4 async Result tasks
  • chain5: etc
  • chain6: etc
  • chain7: etc
  • chain8: etc

The beautiful thing about this chain API is that it retains the same properties as synchronous Result.map chains ... namely, these async chains short-circuit whenever something at the top of the chain results in a Err value 😍

A useful way to think of the chain api is to think of it as the asynchronous alternative to the andThen method.


I've had this issue noodling in my head for a while. Eventually in that same github issue I mentioned at the top of this post, I proposed an approach to chaining many async computations with a set of utility functions.

Before committing to that solution, I started dogfooding this approach through my own side project. After a few days of using this chain API, I concluded that it was in fact quite good and ergonomic.

This API is heavily tested and well-documented!

Cheers!

Top comments (0)