How to Build Real Promise Chains: From Basics to Full Implementation
This article walks through the step‑by‑step creation of a JavaScript Promise implementation, explains why true chaining requires returning a new Promise from then, demonstrates mock asynchronous calls, and provides detailed code examples and execution logs to illustrate the complete chain behavior.
Series Overview
The tutorial series aims to deepen understanding of JavaScript Promises by progressively implementing them, using flowcharts, code examples, and animations.
Part 1 – Basic implementation
Part 2 – Promise chaining
Part 3 – Prototype methods
Part 4 – Static methods
Minimal Promise Implementation
class Promise {
callbacks = [];
state = 'pending'; // added state
value = null; // store result
constructor(fn) {
fn(this._resolve.bind(this));
}
then(onFulfilled) {
if (this.state === 'pending') {
this.callbacks.push(onFulfilled);
} else {
onFulfilled(this.value);
}
return this; // returns the same instance
}
_resolve(value) {
this.state = 'fulfilled'; // change state
this.value = value; // store result
this.callbacks.forEach(fn => fn(value));
}
}This version supports basic resolution but cannot produce true chainable behavior because then returns the same Promise instance.
Why Returning a New Promise Is Required
When each then call returns this, all callbacks share the same result, preventing the progressive transformation of values. True chaining requires then to return a fresh Promise whose resolution depends on the previous one.
function getUserId(url) {
return new Promise(resolve => {
http.get(url, id => resolve(id));
});
}
getUserId('some_url')
.then(id => getNameById(id))
.then(name => getCourseByName(name))
.then(course => getCourseDetailByCourse(course));Each onFulfilled returns a different value, so a new Promise must be created for the next step.
Full Chainable Implementation
class Promise {
callbacks = [];
state = 'pending';
value = null;
constructor(fn) {
fn(this._resolve.bind(this));
}
then(onFulfilled) {
return new Promise(resolve => {
this._handle({ onFulfilled: onFulfilled || null, resolve });
});
}
_handle(callback) {
if (this.state === 'pending') {
this.callbacks.push(callback);
return;
}
if (!callback.onFulfilled) {
callback.resolve(this.value);
return;
}
const ret = callback.onFulfilled(this.value);
callback.resolve(ret);
}
_resolve(value) {
this.state = 'fulfilled';
this.value = value;
this.callbacks.forEach(cb => this._handle(cb));
}
}Key points:
then creates and returns a new Promise, forming the basis of serial chaining.
The pair onFulfilled and the new Promise’s resolve are stored together in the current Promise’s callback queue, linking the current and next Promise.
If onFulfilled is omitted, the next Promise receives the current value unchanged.
Mock Asynchronous Request
/**
* Simulate an asynchronous request
* @param {*} url Request URL
* @param {*} s Delay in seconds
* @param {*} callback Callback after response
*/
const mockAjax = (url, s, callback) => {
setTimeout(() => {
callback(url + ' async request took ' + s + ' seconds');
}, 1000 * s);
};Demo 1 – Simple Chain
new Promise(resolve => {
mockAjax('getUserId', 1, result => resolve(result));
}).then(result => console.log(result));Log output shows constructor, then registration, resolution, and the final printed result.
Demo 2 – Transforming Values
new Promise(resolve => {
mockAjax('getUserId', 1, result => resolve(result));
}).then(result => {
console.log(result);
return 'prefix:' + result;
}).then(exResult => console.log(exResult));The second then receives the transformed value.
Demo 3 – Equivalent Synchronous Version
new Promise(resolve => {
mockAjax('getUserId', 1, result => resolve(result));
}).then(result => {
console.log(result);
const exResult = 'prefix:' + result;
console.log(exResult);
const finalResult = exResult + ':suffix';
console.log(finalResult);
});This shows that when only the first step is asynchronous, the later steps can be written synchronously.
Handling Returned Promises
If onFulfilled returns another Promise, the current Promise must adopt its state. The modified _resolve checks for thenable objects:
_resolve(value) {
if (value && (typeof value === 'object' || typeof value === 'function')) {
const then = value.then;
if (typeof then === 'function') {
then.call(value, this._resolve.bind(this));
return;
}
}
this.state = 'fulfilled';
this.value = value;
this.callbacks.forEach(cb => this._handle(cb));
}Demo 4 – Chaining Promises Returned from onFulfilled
const pUserId = new Promise(resolve => {
mockAjax('getUserId', 1, result => resolve(result));
});
const pUserName = new Promise(resolve => {
mockAjax('getUserName', 2, result => resolve(result));
});
pUserId.then(id => {
console.log(id);
return pUserName; // return another Promise
}).then(name => console.log(name));Logs demonstrate that the second then waits for pUserName to fulfill before executing.
Conclusion
The article provides a complete, step‑by‑step implementation of true Promise chaining, covering basic resolution, callback queue management, handling of thenable values, and practical demos with mock asynchronous calls. Understanding these internals helps developers write reliable asynchronous code and avoid common pitfalls.
vivo Internet Technology
Sharing practical vivo Internet technology insights and salon events, plus the latest industry news and hot conferences.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
