JavaScript promises aren’t actually the promises they were named after. The concept of a promise originated from distributed computation as a linguistic solution to a complex problem. Barbara Liskov needed a better way to work with RPC and call streams, and the promise was the answer.
In the original proposal of the promise, these key tenets emerged.
- Strong types: the result and failure modes of a promise are strongly typed.
- Multiple Claims: the result of a promise could be claimed any number of times.
- Blocking: When a promise is claimed, the process blocks until the value is available.
Through a long series of events, we find JavaScript with a different promise. What was originally a solution for distributed systems became a means to handle asynchronous workloads with callbacks.
Now this piqued my interest. How divergent are these views of promises? To validate this, I attempted to recreate some of the examples from Barbara’s paper in JavaScript. The result was painful.
Recreating Liskov's grade example
Consuming the result of multiple promises creates a mandatory callback that aggregates the result and failure case.
// Simple version of the grade example
let studentGrades = {};
// Simulate some database activity that returns the average
function recordGrade(student, grade) {
return new Promise((resolve, reject) => {
setTimeout(() => {
let grades = studentGrades[student];
grades.push(grade);
resolve(grades.reduce((prev, g) => prev + g, 0) / grades.length)
}, 1);
});
}
function main() {
let targetStudents = [{ name: 'test', grade: 10 }, { name: 'foo', grade: 50 }, { name: 'bar', grade: 100 }];
let averages = [];
for (let student of targetStudents) {
studentGrades[student.name] = [];
averages.push(recordGrade(student.name, student.grade));
}
Promise.all(averages).then(values => {
for (let i = 0; i < targetStudents.length; i++) {
console.log('Student ', targetStudents[i].name, ' has average ', values[i]);
}
}).catch(e => console.error('Got unexpected error ', e));
}
main();
This works, but notice what we had to do? We needed to collect each promise, then wait on them together using Promise.all.
Sequential resolution
Now let's look at resolving them one by one to see how much more difficult it gets.
// Print the student averages as they are available
let studentGrades = {};
let targetStudents = [{ name: 'test', grade: 10 }, { name: 'foo', grade: 50 }, { name: 'bar', grade: 100 }];
let averages = [];
let updateTime = 1;
// Simulate some database activity that returns the average
function recordGrade(student, grade) {
return new Promise((resolve, reject) => {
updateTime = updateTime * 10;
setTimeout(() => {
let grades = studentGrades[student];
grades.push(grade);
resolve(grades.reduce((prev, g) => prev + g, 0) / grades.length)
}, updateTime);
});
}
function printStudent(studentIndex, average) {
return average.then(a => {
console.log('Student ', targetStudents[studentIndex].name, ' has average ', a);
studentIndex++;
if (studentIndex < targetStudents.length) {
return printStudent(studentIndex, averages[studentIndex])
}
}).catch(e => console.error('Got unexpected error ', e));
}
function main() {
for (let student of targetStudents) {
studentGrades[student.name] = [];
averages.push(recordGrade(student.name, student.grade));
}
printStudent(0, averages[0])
.then(() => console.log('Operation success'))
.catch(e => console.error('Got unexpected error ', e));
}
main();
Multiple claim resolution
In the original paper, you could resolve a claim multiple times after it was resolved. In promises, you can do the same, but doing so requires a leap of faith that callbacks happen in registration order.
This is specified, but the callbacks could arguably create some initial discomfort.
If you call the
then()method twice on the same promise object (instead of chaining), then this promise object will have two pairs of settlement handlers. All handlers attached to the same promise object are always called in the order they were added. Moreover, the two promises returned by each call ofthen()start separate chains and do not wait for each other's settlement.
function simplePromise() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('hi');
}, 5000);
});
}
// Resolve a single promise 4 times. Notice this program should
// take ~5 seconds not ~20 seconds.
function main() {
const p = simplePromise();
for (let i = 0; i < 5; i++) {
p.then(v => console.log('Invoke: ', i, ', value: ', v));
}
p.then(r => {
console.log('Promises resolved');
});
}
main();
Two years after Promise was added to ECMAScript, the async/await pattern was added. While it is not perfect, it is far closer to the spirit of Liskov's promises.
Multiple claims with async/await
To demonstrate the impact async/await creates, let’s revisit the last example, but with async/await instead.
function simplePromise() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('hi');
}, 5000);
});
}
async function main() {
let p = simplePromise();
for (let i = 0; i < 5; i++) {
console.log('Invoke: ', i, 'value: ', await p);
}
console.log('Promises resolved');
}
main();
About those types
Now, getting back to those types. Clearly, JavaScript doesn’t have types, so comments about types do not apply. However, TypeScript is a different story. Unfortunately, TypeScript only allows typing the resolved value of a promise. The rejection path remains untyped, meaning we still lose one of the key guarantees Liskov originally proposed.
If you are interested in a more thorough history of promises, I wrote a deeper dive here.
I wouldn’t say that JavaScript promises are wrong. They filled a gap that could only really be solved at the language level. Libraries could mimic the pattern, but only the engine could properly integrate asynchronous work into the runtime.
So, what do you think? If JavaScript promises are not really Liskov’s promises, should we still call them promises at all?
Top comments (0)