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)