DEV Community

Anthony G
Anthony G

Posted on • Updated on

TaskEither vs Promise

Many train tracks

tl;dr

TaskEither extends the powerful railway-oriented paradigm implemented by Promise, and makes it more referentially transparent.

Promises Rock

Promise as a concept dates back to at latest 1976 when the term was proposed by Daniel P. Friedman and David Wise. The idea started catching on in the javascript community in the late 2000s.

In July of this year (2020), Sam Saccone published a remarkable article outlining in detail the history of javascript's Promise. It's a fun read, I consider it definitive and I recommend it. I will summarize it here.

Before Promise, there was XMLHttpRequest, which used continuation passing style (we'll explain this later). Then, there was MochiKit in 2005, the first implementation of Promise in javascript. This was followed by Dojo's deferredRequest in 2006 (Dojo was the predecessor to jQuery), which was decoupled into Deferred in 2007, followed by Promise in Waterken Q in 2009, which that same year became Q, which influenced Dojo to adopt Promise, followed by jQuery's deferred in 2010.

If that all sounds like a mess, that's because it was. There was no clear way forward - new implementations kept being released and no one knew which one would win out and become standard. Then, in 2010, many important foundational asynchronous web apis began shipping (Webcrypto, fetch, IndexedDB, localStorage). This raised the stakes on what was already a serious vhs/beta situation.

A year earlier, in 2009, Kris Zyp proposed a unified interface that would cover most of these implementations and allow them to inter-operate. This would later became known as the 'Promises/A' spec. Paul Chavard wrote a test suite for it in the Ember.js library, though it was flawed - it didn't correctly describe the 'Promises/A' spec. Domenic Denicola fixed it up and thereby created the 'Promises/A+' spec, which he described in his talk Boom, Promises/A+ Was Born.

In 2015, 'Promises/A+' became officially codified into ECMAScript 2015 (also known as es6). To quote Saccone:

The meta win here was the fact that having multiple agreeing implementations showed the TC39 that there was community consensus around the idea of Promises… an idea that a few years earlier was thought to be too esoteric for common use.

Because of this, Promise is now supported natively by 96% of browsers worldwide. 'Promises/A+' eventually would unify over 70 promise implementations, massively reducing complexity and confusion.

Though the A+ spec had to make serious compromises that we'll discuss later, I think it's worth mentioning the tremendous accomplishment that the compromises allowed: a unified Promise spec that saved us all from having to learn several different standards, and maybe saved the very idea of a javascript Promise.

Promises are Callback Heaven

Before promises, asynchronous operations had to be done using callbacks. Here's an example stolen from Arfat Salman

queryDatabase({ username: 'Arfat' }, (err, user) => {
  // handle errors database querying failure
  const image_url = user.profile_img_url;
  getImageByURL(`someServer.com/q=${image_url}`, (err, image) => {
    // handle errors fetching failure
    transformImage(image, (err, transformedImage) => {
      // handle errors transformation failure
      sendEmail(user.email, (err) => {
        // handle errors of email failure
        logTaskInFile('transformed the file and sent user an email', (err) => {
          // handle errors of logging failure
        })
      })
    }])
  })
})
Enter fullscreen mode Exit fullscreen mode

The above code uses something called continuation passing style, although it's more often alarmingly referred to as 'callback hell'. According to Colin Toh

Callback Hell, also known as Pyramid of Doom, is an anti-pattern ... It consists of multiple nested callbacks which makes code hard to read and debug.

Here's the same code, written using Promise instead

queryDatabase({ username: 'Arfat' })
  .then((user) => {
    const image_url = user.profile_img_url;
    return getImageByURL(`someServer.com/q=${image_url}`)
      .then(image => transformImage(image))
      .then(() => sendEmail(user.email))
  })
  .then(() => logTaskInFile('...'))
  .catch(() => handleErrors()) // handle all errors
Enter fullscreen mode Exit fullscreen mode

This control flow is improved. The power here is in modelling a callback as a value called a Promise.

Promises are Declarative

From James Coglan's article Callbacks are Imperative, Promises are Functional (emphasis added)

If object-oriented programming treats everything as an object, functional programming treats everything as a value – not just functions, but everything.

... At its best, functional programming is declarative. In imperative programming, we write sequences of instructions that tell the machine how to do what we want. In functional programming, we describe relationships between values that tell the machine what we want to compute, and the machine figures out the instruction sequences to make it happen.

More on that fuzzy word 'declarative' later.

Promises are Kinda Monads

then allows operations to run in sequence. This is a necessary trait for monads, which are also known as 'programmable semicolons'.

Monads are sometimes controversially1 described as containers or wrappers (burritos). Promise fits this metaphor nicely - a Promise 'wraps' a value in a 'container' that the value can never come out of - it exists in that container forever, only able to be changed from within (unless you are cheating with async/await - more on that later). In the burrito metaphor, the Promise is the tortilla and the value is the beans and cheese2.

Promise is not in fact a monad, but it's similar enough to be worth mentioning. We'll get to the differences later, but if you already understand monads, maybe now you understand Promise a little better, and vice-versa.

Control Flow

Here is an example of error handling using async/await syntax

try {
  const val = await getVal()
  try {
    const otherVal = await val.getOtherVal()
  } catch (err: any) {
    console.error('handle getOtherVal error')
  }
} catch (err: any) {
  console.error('handle getVal error')
}
Enter fullscreen mode Exit fullscreen mode

Here is the same code with simpler control flow using vanilla Promise:

const otherVal = getVal()
  .catch(() => console.error('handle getVal error'))
  .then(val => val.getOtherVal())
  .catch(() => console.error('handle getOtherVal error'))
Enter fullscreen mode Exit fullscreen mode

Here's an example from Jake Archibald's JavaScript Promises: an Introduction

Complex async flow

Here's the implementation using Promise

asyncThing1()
  .then(asyncThing2)
  .then(asyncThing3)
  .catch(asyncRecovery1)
  .then(asyncThing4, asyncRecovery2)
  .catch(() => console.log("Don't worry about it"))
  .then(() => console.log("All done!"))
Enter fullscreen mode Exit fullscreen mode

You can imagine that it would be much more complicated to implement this using async/await. Simple promises have more powerful error handling than async/await syntax3.

Promise also allows for expression-oriented programming, which encourages purity and is easier to reason about than statement-oriented async/await.

Railway oriented programming

Promise is able to easily handle this kind of complicated control flow because with one value it models either a rejection or a resolution. This allows combinators like catch to handle values implicitly passed down from earlier operations. Take this example:

const response = validate(request)
  .then(update)
  .then(send)
Enter fullscreen mode Exit fullscreen mode

Here's a picture representing this code visually

railway oriented programming

That picture is from Scott Wlaschin's influential Railway oriented programming. You can see two separate 'tracks' - a 'resolution' track above, and a 'rejection' track underneath. In the parlance of railfans, we say that catch can 'shunt' our rejection cases back onto the resolution track, while then can shunt them right back onto the rejection track.

TaskEither takes railway oriented programming to the next level with an abundance of operators. They're more specific than then and catch and cover much more potential functionality.

                 reject(x)  resolve(y)
                       \      /
                  :     |    |     :
      TE.map (f)  :     |   f y    :  The 'map' function affects the value in
                  :     |    |     :  the resolution track, but if the train
                  :     |    |     :  would've been on the rejection track,
                  :     |    |     :  nothing would've happened.
                  :     |    |     :
    TE.chain (f)  :     |   f y    :  The 'chain' function affects the value in
                  :     |   /|     :  the resolution track, and allowed the
                  :     |  / |     :  train to change tracks, unless it was
                  :     | /  |     :  already on the rejection track.
                  :     |/   |     :
                  :     |    |     :
                  :          |     :
                  :     -    |     :
                  :     |    |     :
      TE.alt (m)  :     m    |     :  The 'alt' function replaces a train on
                  :     |\   |     :  the rejection track with another one,
                  :     | \  |     :  allowing it to switch tracks.
                  :     |  \ |     :
                  :     |   \|     :
   TE.orElse (f)  :    f y   |     :  The 'orElse' function is the opposite
                  :     |\   |     :  of the 'chain' function, affecting the
                  :     | \  |     :  rejection branch and allowing a change
                  :     |  \ |     :  back to the resolution track.
                  :     |   \|     :
  TE.fold (f, g)  :    f x  g y    :  The 'fold' function affects both
                  :      \   |     :  tracks, but forces the train to switch
                  :       \  |     :  from the rejection track back to the
                  :        \ |     :  resolution track.
                  :         \|     :
                  :          |     :
                             V
Enter fullscreen mode Exit fullscreen mode

(This is an adaptation of a railway diagram from Aldwin Vlasblom's article on his Fluture library, which is similar to fp-ts. We'll explore Fluture more in the next article)

Problems with promises

Model unchecked exceptions

Errors propogated by Promise are typed as any, which is not good

const resource: Promise<Resource> = getResource()
resource
  .then((r: Resource) => ...)
  .catch((error: any) => ...)
Enter fullscreen mode Exit fullscreen mode

It's also legal to completely omit a catch operation

const resource: Promise<Resource> = getResource()
resource
  .then((r: Resource) => ...)
  //.catch((error: any) => {}) <--- this is perfectly legal
Enter fullscreen mode Exit fullscreen mode

This is because Promise models unchecked exceptions, which we covered in our last article, Either vs Exception Handling. The modern solution to this is the Either type, which of course is part of TaskEither

import * as TE from 'fp-ts/TaskEither'

const resource: TE.TaskEither<ResourceError, Resource> = getResourceTaskEither()
Enter fullscreen mode Exit fullscreen mode

You can see right in the type signature of resource that it may return a ResourceError. We know at compile-time exactly which errors are possible, and we can't invoke a Task without handling both of it's Either cases (or at leas we shouldn't).

Then and catch are vague

then and catch are poorly defined functions. They have many different usages that look similar but behave differently. Here, I've noted the equivalent operations for TaskEither, which are more descriptive.

import { pipe } from 'fp-ts/pipeable'
import * as E from 'fp-ts/Either'
import * as T from 'fp-ts/Task'

const transformValueP = Promise.resolve(3)
  .then(n => n + 3)
const transformValueTE = pipe(
  TE.right(3),
  TE.map(n => n + 3)
)

const chainAsyncP = Promise.resolve(3)
  .then(n => Promise.resolve(n + 3))
const chainAsyncTE = pipe(
  TE.right(3),
  TE.chain(n => TE.right(n + 3))
)

const handleAllErrorsP: Promise<number> = Promise.reject(new Error('Illegal'))
  .catch(() => 3)
const handleAllErrorsTE: T.Task<number> = pipe(
  TE.left(new Error('Illegal')),
  T.map(E.getOrElse(() => 3)),
)

const asyncOnErrorP: Promise<number> = Promise.reject(new Error('Illegal'))
  .catch(() => Promise.resolve(3))
const asyncOnErrorTE: TE.TaskEither<Error, number> = pipe(
  TE.left(new Error('Illegal')),
  TE.orElse(() => TE.right(3)),
)

const handleErrorAndValueP: Promise<number> = Promise.resolve(3)
  .then(
    n => n + 3,
    () => 3,
  )
const handleErrorAndValueTE: T.Task<number> = pipe(
  TE.right(3),
  T.map(E.fold(
    () => 3,
    n => n + 3
  )),
)
Enter fullscreen mode Exit fullscreen mode

Also note that the type of handleAllErrorsTE and handleErrorAndValueTE is Task<number>, while the rest are TaskEither - this is more informative than the Promise<number> type that all of the Promise values share.

Non obvious difference btwn catch and then

then can accept an error handling callback.

const example1: Promise<void> = Promise.resolve(3)
  .then(console.log)
  .catch(console.error)

const example2: Promise<void> = Promise.resolve(3)
  .then(
    console.log,
    console.error,
  )
Enter fullscreen mode Exit fullscreen mode

These examples are functionally the same. So what's the difference between using the second parameter of then and catch?

Here's an example where the behavior is different:

const example3: Promise<number> = Promise.resolve(3)
  .then(() => Promise.reject(new Error('Illegal')))
  .catch(() => 13)
// example3 === Promise.resolve(13)

const example4: Promise<number> = Promise.resolve(3)
  .then(
    () => Promise.reject('Illegal'),
    () => 13,
  )
// example4 === Promise.reject(Error('Illegal'))
Enter fullscreen mode Exit fullscreen mode

The difference arises because both callbacks for then can optionally be asynchronous. This means that the catch from example3 can handle rejections from it's then operation, while the error callback in example4 doesn't get the chance to handle the error thrown by it's success callback.

Here's a railway diagram describing the problem (stolen from this article on coding game)

then vs catch railway diagram

Here's the equivalent using Task - note that example5 and example6 have entirely different types

const example5: T.Task<number> = pipe(
  TE.right(3),
  TE.chain(() => TE.left(new Error('Illegal'))),
  T.map(E.getOrElse(() => 13)),
)
const example6: TE.TaskEither<Error, number> = pipe(
  TE.right(3),
  TE.fold(
    () => TE.right(13),
    () => TE.left(new Error('Illegal'))
  ),
)
Enter fullscreen mode Exit fullscreen mode

Promiselike

Quote from Aldwin Vlasblom's Broken Promises (warning: paywall)

At the time of drafting the Promises/A+ specification, there were already several existing and popular Promise libraries, and it was important for different implementations to inter-operate. Because of this, it was decided that when users return an object with a then-function into their Promise, it should be assimilated as if it's a Promise.

...Automatic assimilation has some drawbacks though:
Firstly, the fact that the mere existence of a then function on an object will make Promises change behaviour, exposes users to the potential danger of accidentally supplying something that looks too much like a Promise, and causing the program to misbehave. Execute the following for an example of Promises misbehaving:

Promise.resolve({ then: () => console.log("Hello!") }).then(() => {
  console.log('this will never be run')
})
// output:
// Hello!
Enter fullscreen mode Exit fullscreen mode

It is also possible, though extremely difficult, to conform to PromiseLike accidentally, as I discovered writing this example code.

Poor Referential Transparency

Here we see a data structure containing two Promises

const twoPrints: [Promise<void>, Promise<void>] = [
  new Promise(res => {
    console.log('vote!')
    res()
  }), 
  new Promise(res => {
    console.log('vote!')
    res()
  })
]
// output
// vote!
// vote!
Enter fullscreen mode Exit fullscreen mode

There is duplicate code here. What if we factor it out?

const print: Promise<void> = new Promise(res => {
  console.log('vote!')
  res()
})
const twoPrints: [Promise<void>, Promise<void>] = [
  print,
  print
]
// output
// vote!
Enter fullscreen mode Exit fullscreen mode

Promise has cached the result of print (which was void), and will therefore not run it again.

We can do with Task.

import * as Task from 'fp-ts/Task'

const print: Task<void> = () => new Promise(res => {
  console.log('vote!')
})
const twoPrintsRefactored: [T.Task<void>, T.Task<void>] = [
  print,
  print
]
twoPrints.map(invoke => invoke())
// output
// vote!
// vote!
Enter fullscreen mode Exit fullscreen mode

(Example adapted from this SO post about Scala's Future)

This property is called 'referential transparency'. If code is referentially transparent, we can always refactor duplicate code without changing the expected behavior. Referential transparency means that any expression can be replaced by its value without changing the behavior of the program. Things are what they look like - our intentions are 'transparent'.

Promise is therefore less referrentially transparent than Task is - although they both execute code that could affect the outside world, so neither of them can ever be fully transparent4.

This is an extension of the same idea we discussed in the first section - although Promise mererly represents a callback pattern as a value, Task (due to its laziness) represents an entire asynchronous execution as a value.

Promises aren't Monads

A rigidly defined category theoretical monad conforms to three different laws. Here the one called 'left identity'

const printLine = (line: string): Promise<void> =>
  new Promise(res => setTimeout(res, 0))
    .then(() => console.log(line))

// Left Identity: the following two statements are equivalent
printLine('vote!')
// output
// 'vote!'

Promise.resolve('vote!').then(printLine)
// output
// 'vote!'
Enter fullscreen mode Exit fullscreen mode

However, Promise.resolve flattens & invokes any Promise passed into it, which breaks the left identity.

const printFirstThen = (next: Promise<void>): Promise<void> =>
  new Promise(res => {
    console.log('first')
    res()
  }).then(() => next)

printFirstThen(printLine('second'))
// output:
// first
// second

Promise.resolve(printLine('second')).then(printFirstThen) // type error
// Type 'void' is not assignable to type 'Promise<void>'
Enter fullscreen mode Exit fullscreen mode

It's a type error - it won't even run. But even if we finagle the value to compile, it still has unexpected behavior:

Promise.resolve(printLine('second'))
  .then(output => printFirstThen(Promise.resolve(output)))
// output:
// second
// first
Enter fullscreen mode Exit fullscreen mode

This happens because Promise.resolve invokes the Promise returned by printLine and forces it to resolve before moving on to printFirstThen.

Hopefully this example shows that this is strange behavior that can lead to unexpected results.

Here's how Task is able to pass the left identity:

const printLineT = (line: string): T.Task<void> =>
  () => printLine(line)
const printFirstThenT = (next: T.Task<void>): T.Task<void> => pipe(
  () => new Promise(res => {
    console.log('first')
    res()
  }),
  T.chain(() => next),
)

printFirstThenT(printLineT('second'))
// output:
// first
// second

pipe(
  T.of(printLine('second')),
  T.chain(printFirstThen),
  invoke => invoke()
)
// output:
// first
// second
Enter fullscreen mode Exit fullscreen mode

This is an example of the practical engineering benefit of the monad laws: the broken left identity tangibly makes Promise less consistent & predictable than the behavior of Task.

Promises can't nest

There are times where we might want to nest Promise. For example, let's say we have the following two functions.

const getInput = (): Promise<string> => ...
const query = (input: string): Promise<Response> => ...
Enter fullscreen mode Exit fullscreen mode

This function composing the two would be nice:

const queryFromInput = (): Promise<Promise<Response>> => ...
Enter fullscreen mode Exit fullscreen mode

From the Promise/A+ spec feature request proposing nestable promises (originally posted in 2013):

This type is quite exquisite. It explicitly shows that there are two different threads of execution involved here - three if you include the present thread of execution - and we have access to either one. In fact, we have access to the response thread from within the userInput thread, which is something we lack if Promises are pre-flattened.

Again, although the type signature is legal, this function is impossible to implement due to the strange auto-flattening nature of promises.

Eagerness Complicates Nestability

Even if it could exist, a nested Promise would pose a problem. Since the creation of a Promise immediately invokes its execution, it's difficult to predict how a nested Promise would behave. The inner Promise could execute before the outer Promise, or vice versa - there's no way to know without looking at the implementation details.

// if promises nested, we'd have a 'map' that wouldn't flatten
Promise.prototype.map = <A>(a: A): Promise<A> => ...

//         this one executes first
//                  vvv
const outerFirst: Promise<Promise<void>> = new Promise(res => {
  console.log('outer first')
}).map(() => new Promise(res => {
  console.log('inner next')
}))

const inner = new Promise(res => {
  console.log('inner first')
})
//                 this one executes first
//                          vvv
const innerFirst: Promise<Promise<void>> = new Promise(res => {
  console.log('outer next')
}).map(() => inner)
Enter fullscreen mode Exit fullscreen mode

Eager evaluation does not preclude nestability - Scala's Future unfortunately has both. It has the behavior described above, which is unnessecarily complicated and not very referentially transparent.

Nested Tasks always run from the innermost component outward.

const inner: T.Task<void> = () => new Promise(res => {
  console.log('inner')
})
const outer: T.Task<void> = () => new Promise(res => {
  console.log('outer')
})
const innerFirst: T.Task<T.Task<void>> = pipe(
  outer,
  T.map(() => inner),
)
// output:
// inner
// outer
Enter fullscreen mode Exit fullscreen mode

Once again, the behavior of Task is more consistent than the behavior of Promise. We'll discuss eager vs. lazy evaluation in the next article, TaskEither vs Fluture.

Conclusion

Promise is powerful. It has made javascript into a first class language for asynchronous programming. I hope Domenic Denicola's legacy is his tremendous 'Promises/A+' spec, and not his infamous github comment criticising typed functional programming (the javascript fp library 'fantasy-land' was named after it).

Though I disagree with his statement, I understand where he was coming from. He was carrying the weight of dozens of proposals and the future of asynchronous programming - as javascript becomes more popular outside of the web (nodejs, react native, electron, ink, etc), it becomes clearer that the success of 'Promises/A+' had staggering implications. He must have felt that one wrong move could bring it all toppling down.

Not to give all the credit to Denicola - the idea is 45 years old, and it took many people many ideas and iterations to get to the point of 'Promises/A+'. I just would have felt wrong writing an article about Promise from a functional perspective without mentioning that github thread. If not for the aggressive and cyclical nature of the debate (on both sides), would recommend reading it - it's entirely about the practicality of functional programming & category theory. There's also a similar thread about Promise.resolve here - read at your own peril.

Needless to say, as great as Promise is, TaskEither is even better. It resolves many the pitfalls outlined above, and adds a ton of functionality. With its laziness, referential transparency, type-safety, and advanced yet simple control flow, TaskEither is the culmination of the best asynchronous programming techniques available.


  1. Some people dislike the burrito metaphor - they correctly assert that it only describes a few monads, and only leads to more confusion later when introduced to more. Here are a couple of paradigmatic tweets (1, 2). 

    However, the paper What we Talk About When we Talk About Monads posits that such metaphors are necessary for complete understanding of the concept, alongside 'formal' and 'implementation'-level knowledge.

  2. Except you can't eat the burrito, because the filling doesn't exist yet. All you can do is tell the burrito that when it eventually does get filled with beans and cheese, you will want to modify them, like mash them up or something. Which of course means that you immediately throw out your first burrito, and now you have a new one, which is still an empty shell but now when it resolves, it'll all be mashed up, just how you like. 

    Here's where it gets exciting - you can also tell the burrito that when it eventually exists, you've got another entirely different burrito recipe that needs to use your first burrito's filling - it'll take your beans turn them into some kind of new cassoulet burrito. So now you've thrown out the first burrito, and you've got a new tortilla containing a tortilla which is waiting for the beans and then waiting for the cassoulet to finish. I know what you're thinking - I don't want two tortillas in my burrito! But your burrito is so smart, it automatically yanks the middle shell out of there and throws it away so that you only have one shell. And you can go on chaining burritos together as much as you like! And this ability to nest the burritos and yank the tortilla - this is the essence of what it means to be a true burrito.

    Sometimes, though, you might want both shells - you might want to be able to tell where the outer burrito ends and the inner one begins. Except it's an unlawful burrito, so it'll yank the middle shell out whether you want it to or not!

    It's a little like Chipotle.

  3. async/await syntax is great for asynchronous operations that don't need complex error handling and whose usage relies on many other asynchronous operations, like react-testing-library (although do-notation can help with this as well). 

  4. This is part of the criticism some people have of the word 'declarative' - while Promise is the 'declarative' alternative to a callback, a Task is also 'declarative'. Task is more referentially transparent, but is it correct to say that it's 'more' declarative? It's a fuzzy word without a clear definition. 

    Peter Landin discussed this in his seminal 1966 paper The Next 700 Programming Languages introducing his ISWIM language (apparently this problem is 55 years old). He suggests an alternative word that implies purity - 'denotative', from the realm of mathematics

    The commonplace expressions of arithmetic and algebra have a certain simplicity that most communications to computers lack. In particular, (a) each expression has a nesting subexpression structure, (b) each subexpression denotes something (usually a number, truth value or numerical function), (c) the thing an expression denotes, i.e., its “value”, depends only on the values of its subexpressions, not on other properties of them.

    ...The word “denotative” seems more appropriate than nonprocedural, declarative or functional. The antithesis of denotative is “imperative.”

    By this definition, neither Promise nor Task is denotative because they perform side-effects. A truly useful word!

Discussion (0)