Pretty Please
Pretty Please is a TypeScript library that provides Tasks as an alternative to Promises. Tasks are a very 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 await
ed 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)
}),
notificationsApi.getMessages(),
user.getProjects(),
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.