Skip to content

Latest commit

 

History

History
155 lines (104 loc) · 7.95 KB

README.md

File metadata and controls

155 lines (104 loc) · 7.95 KB

Pretty Please

Test Coverage npm latest version

Pretty Please is a TypeScript library that provides Tasks as an alternative to Promises. Tasks are a common solution to asynchronicity (see Prior Art below) and can be used in place of Promises. They differ from Promises in several ways.

Lazy Execution

Tasks provide lazy execution. Tasks do not start running until some piece of code uses the result of the Task. This means that if the value is never used, the computation and loading never happens. Promises always start in a "pending" state, making it difficult to control them externally.

Cancellation

Tasks can be cancelled. Promises can not. This means that when you are loading data and you navigate away from the page, you can stop the asynchronous requests. Often with Promises, you will see them complete long after they are needed (especially when quickly moving through a single page web app).

Better Error Handling

Promises do not track what kind of errors can be thrown by the Promise. The type of Promise in TypeScript is Promise<T>. It only knows or cares about the successful result type. The type of Task is Task<E, T>, where E is the expected error type of the task. In Pretty Please, we force you to deal with errors up front. All functions and types which interact with tasks put the error handling before the success handling.

Asynchronous programming is difficult and error prone. We must be constantly thinking about how processes can fail.

Functions should accomplish 1 thing

It is difficult to understand how the Promise.prototype.then and Promise.prototype.catch methods of Promise work. This is because they accomplish more than one thing and work differently depending on how they are used.

Promise.prototype.then provides success and error handling. It also provides the ability to chain both successes and errors to either realized values or to a pending Promise.

Tasks provide separate methods (with their own types) for each use case:

  • map transforms successful tasks by running a mapping function on the value. Similar to Promise.prototype.then when returning a realized value.
  • mapError transforms an error from one type to another. This would require Promise.prototype.then to return a Promise.reject.
  • mapBoth does both at once.
  • chain takes a successful task and chains it to the next asynchronous action. Similar to Promise.prototype.then where the return value is a pending Promise.
  • tap and log allow looking (or logging) the success value without transforming it.
  • and there are many more.

Promise.prototype.catch allows for not just error handling and logging, but also for recovery. Developers often accidently turn a failing Promise into a successful one by attempting to insert error logging.

Convenience Methods

Back in the day, before Promises were in Javascript, I used Bluebird.js for asynchronous code (maybe you did too). If you take a look at their API, you'll see nearly 40 different methods of dealing with Promises. And yet, when Promises became standardized we lost almost all of these. Helpers like Promise.race and Promise.prototype.finally have only made their way into the language recently.

Working with asynchronicity requires that our approaches and functions provide readable code. Promises force almost all code to be inside Promise.prototype.then blocks whose intention is difficult to scan.

Pretty Please provides dozens of well-named helper methods to make sure your logic is readable and scannable. We also provide tools for error recovery such as retryIn. This attempts to recover failing Tasks by retrying them after waiting some number of milliseconds.

async/await

I believe that most Javascript programmers have a hard time working with Promises. That's actually okay. Asynchronous programming is hard. The web platform has introduced async/await in an attempt to improve the situation. I believe that this was actually a mistake. async/await makes asynchronous programming *look easy. But it distances the programmer from the complexity of the system.

Side bar: here come the generalizations. I'm sure you are a smart programmer and you never make these mistakes, but I have reviewed hundreds of Javascript programmers code and I see this repeated often.

Many tasks which are parallelizable end up becoming serial when converted to async/await. The paradigm wants us to think imperatively and linearly, which does not match up well to parallelizable asynchronicity.

For integration purposes, Tasks can be awaited just like Promises. If you have a large async/await codebase, using Tasks will look exactly like using Promises.

Code Comparison

Here's a quick example that might seem complicated, but is actually a very simple problem programmers encounter.

Imagine a site which works like Github. There are Users who have Projects. Those users also have Friends. Those Friends have Projects. There are also global Notifications.

Load all the Projects that the User can access (both theirs and their friends) and load the notifications.

Promises

The following can be written using Promises and it is both readable and performant. However it is not lazy and if it were written using async/await, the parallelism would likely be omitted.

function Loader(
  user: User,
  notificationsApi: NotificationsAPI,
): Promise<Result> {
  return Promise.all([
    notificationsApi.getMessages(),

    Promise.all([
      user.getProjects(),

      user
        .getFriends()
        .then(friends =>
          Promise.all(friends.map(friend => friend.getProjects())).then(
            flatten,
          ),
        ),
    ]).then(([myProjects, friendsProjects]) =>
      myProjects.concat(friendsProjects).map(project => project.name),
    ),
  ]).then(([notifications, projectNames]) => ({
    projectNames,
    notifications,
  }))
}

Tasks

It is important to remember that this is all lazy. So until the data is used to render the page, the request does not start.

function Loader(
  user: User,
  notificationsApi: NotificationsAPI,
): Task<Error, Result> {
  return Task.map3(
    notifications => myProjects => friendsProjects => ({
      notifications,

      projectNames: myProjects
        .concat(friendsProjects)
        .map(project => project.name),
    }),

    // Get Notifications
    notificationsApi.getMessages(),

    // Get My Projects
    user.getProjects(),

    // Get My Friends Projects
    user
      .getFriends()
      .map(friends => friends.map(friend => friend.getProjects()))
      .chain(Task.all)
      .map(flatten),
  )
}

Installation

Yarn

yarn add @tdreyno/pretty-please

NPM

npm install --save @tdreyno/pretty-please

Prior Art

Many languages handle asynchronicity in this way. Elm provides Tasks, so does Java, C# and F#.

Rust uses Futures, along with Scala.

fp-ts is a popular TypeScript library which implements functional concepts.

License

Pretty Please is licensed under the Hippocratic License. It is an Ethical Source license derived from the MIT License, amended to limit the impact of the unethical use of open source software.