JavaScript Promise
# JavaScript Promise
# Why do we need Promise
# What is Promise
Promise represents the eventual completion (or failure) of an asynchronous operation and allow us to work with the results when they become available.
Promise acts as a container that stores the results of an event happening in the future (usually asynchronous).
# Promise states
Promises have three states: Pending, Fulfilled, or Rejected.
- Pending
Initial state of a promise. It represents that the asynchronous operation is still ongoing and hasn’t completed yet.
- Fulfilled
The state of a promise when the asynchronous operation is successfully completed. It means that the promised result or value is available.
- Rejected
The state of a promise when the asynchronous operation encounters an error or fails. It means that the promised result cannot be obtained.
Promises provide methods like ``.then()and
.catch()` to handle the resolved values or errors.
State transition:
- Promise can only go from "pending" state to either "fulfilled" or "rejected" state.
- Once the Promise object's state changed, it cannot be changed again and will maintain this state.
- If you add callbacks for a resolved Promise object, you will immediately get the result back.
- This is different from Event where if you missed the initial event when it fired, you will not get the result again by listening to it.
# Promise patterns
# Constructor
const promise = new Promise(function(resolve, reject)) {
// ... some code
if (/* async op succeeded */) {
resolve(value);
} else {
reject(error);
}
};
The Promise constructor takes in a function and that function takes in two parameters, resolve
and reject
. These functions are supplied by the JavaScript engine.
resolve
function changes the status of the Promise object from "pending" to "fulfilled". It's usually called when an async operation successfully completes and we are ready to deliver the result.
reject
function updates the status from "pending" to "reject". It's used when async operation failed, we could pass it the error object, stack trace, or other useful information about the failure.
# Promise.prototype.then()
After we create an instance of Promise using the constructor above, we can use then
function to specify the callback functions for when the Promise object resolves to either "fulfilled" or "rejected" state. These callbacks will be triggered whenever the state transition happens.
promise.then(
function (value) {
// success callback
},
function (error) {
// failure callback
}
);
Both of these callbacks are optional. They takes in whatever we have resolved(value)
or reject(err)
as parameters.
Here is an example of a simple timeout promise.
function timeout(ms) {
return new Promise((resolve, reject) => {
setTimeout(resolve, ms, "done");
});
}
timeout(100).then((value) => {
console.log(value);
});
In this example, timeout
function returns a Promise object. After specified time (ms
), the Promise object status becomes resolved
, the success callback function passed into the then
function will be called.
Promise chain
Promise chaining allow us to avoid "callback hell" or "pyramid of doom" that can result from using nested callbacks to handle asynchronous code.
With promise chaining, we can perform multiple asynchronous operations in sequence, with each operation starting when the previous one has completed.
This is possible because then
method on a Promise object returns
either
- a new resolved Promise instance that is resolved with value returned from the success callback or
- a new pending Promise instance from another async operations within the callback.
firstAsyncFunction()
.then((firstResult) => secondAsyncFunction(firstResult))
.then((secondResult) => thirdAsyncFunction(secondResult))
.then((finalResult) => {
// handle final result
})
.catch((error) => {
// handle error
});
Error Handling
Error can be caught at any point in the promise chain. If an error occurs, execution skips to the nearest .catch()
handler down the chain.
firstAsyncFunction()
.then((result) => secondAsyncFunction(result))
.then((result) => {
throw new Error("Something went wrong!");
})
.then(() => {
// This will be skipped.
})
.catch((error) => {
console.log(error.message); // Output: "Something went wrong!"
});
Combining with Promise.all
We can also combine promise chaining with Promise.all
for parallel execution:
Promise.all([asyncTask1(), asyncTask2(), asyncTask3()])
.then(([result1, result2, result3]) => {
return nextAsyncTask(result1);
})
.then((finalResult) => {
// Do something with the final result
})
.catch((error) => {
// Handle any error that occurred above
});
# Promise.prototype.catch()
.catch(rejection)
is equivalent to .then(null/undefined, rejection)
promise
.then((val) => console.log("fulfilled:", val))
.catch((err) => console.log("rejected", err));
// is equivalent to
promise
.then((val) => console.log("fulfilled:", val))
.then(null, (err) => console.log("rejected:", err));
When a Promise is already resolved, throwing errors afterwards doesn't do anything. This is because once a Promise's state has changed, it cannot be changed again.
const promise = new Promise(function (resolve, reject) {
resolve("ok");
throw new Error("test");
});
promise
.then(function (value) {
console.log(value);
})
.catch(function (error) {
console.log(error);
});
// ok
Internally, catch()
actually calls then()
on the object and passes in undefined
and onRejected
as arguments. This means we can also chain on the Promise object returned from catch
. Although using then
and catch
both captures error, it is recommended to always use catch
because:
- Using
catch
we can also capture errors from the precedingthen
function. - This format is more similar to
try / catch
structure, thus easier to read and understand.
// ok
promise.then(
(value) => {
/* success callback */
},
(err) => {
/* handle error */
}
);
// better
promise
.then((value) => {
/* success callback */
})
.catch((err) => {
/* handle error */
});
# Promise.prototype.finally()
Finally, we can talk about finally()
method. It usually appears at the end of a Promise chain. Its callback parameter is executed regardless of the final state of Promise object.
promise
.then(result => {...})
.catch(error => {...})
.finally(() => {...});
In the above example, whether the Promise object resolves and run the callback function in then
or rejects and run the callback function in catch
, it will run finally
's callback function in the end.
An example usage of Promise is to stop a server after a request is resolved.
server
.listen(port)
.then(function () {
// ...
})
.finally(server.stop);
Internally, finally
is implemented with then
, similar to catch
.
Promise.prototype.finally = function (callback) {
let P = this.constructor;
return this.then(
(value) => P.resolve(callback()).then(() => value),
(err) =>
P.resolve(callback()).then(() => {
throw err;
})
);
};
From this implementation, we can observe that finally
will always return the value of the promise (either resolved value or thrown error) after running its callback.
# Promise.all([...])
# Promise.race([...])
# Promise.allSettled([...])
# Promise.any([...])
# Promise.resolve(...)
and Promise.reject(...)
# Promise anti-patterns
# Nested Promise
Avoid nested Promise. Use Promise chaining for sequential async tasks and Promise.all
for parallel tasks.
// bad
loadSomething().then(function (something) {
loadAnother().then(function (another) {
DoSomethingOnThem(something, another);
});
});
// good
Promise.all([loadSomething(), loadAnother()]).then(function ([
something,
another,
]) {
DoSomethingOnThem(...[something, another]);
});
# Broken Promise chain
Be careful with Promise chaining since they return a new Promise object.
// bad
function someAsyncCall() {
var promise = doSomethingAsync();
promise.then(function (val) {
doSomethingWithVal();
});
return promise; // this is not the promise returned by `then`
}
// correct
function someAsyncCall() {
var promise = doSomethingAsync();
return promise.then(function (val) {
doSomethingWithVal();
});
}
# Uncaught exceptions
If we use the rejection callback on then
instead of chaining it with another then
or catch
, we cannot capture errors thrown from the success callback.
// bad
somethingAsync().then(
function () {
return somethingElseAsync();
},
function (err) {
handleMyError(err);
}
);
In this example, any errors thrown from somethingElseAsync()
is not caught. Better approach is to use chaining with either catch(rejectCallback)
or then(undefined, rejectCallback)
.
// good
somethingAsync()
.then(function () {
return somethingElseAsync();
})
.catch(function (err) {
handleMyError(err);
});
# Limitation of Promise
# Silenced exception
Let's look at an example where exception not caught within Promise doesn't affect JavaScript code execution.
const someAsyncThing = function () {
return new Promise(function (resolve, reject) {
resolve(x + 2); // ReferenceError not caught
});
};
someAsyncThing().then(function () {
console.log("everything is great");
});
setTimeout(() => {
console.log(123);
}, 2000);
// Uncaught (in promise) ReferenceError: x is not defined
// 123
When browser encounters the statement within Promise's function, it prints out the error ReferenceError: x is not defined
, but it will not terminate the process or stop the script from running. It still outputs the 123
after 2 seconds. This means, any error occurred in Promise will not affect code running outside the Promise, the Promise "ate" or "silenced" the error.
Therefore, we should always add catch
function at the end of a Promise object to properly handle errors within Promise.
# Single value
# Implement Promise
const STATUS = {
RESOLVED: "resolved",
REJECTED: "rejected",
PENDING: "pending",
};
export class MyPromise {
status = STATUS.PENDING;
value = null;
onResolveCallbacks = [];
onRejectCallbacks = [];
construct(handler) {
try {
handler(this.onResolve, this.onReject);
} catch (err) {
this.onReject(err);
}
}
onResolve(value) {
if (this.status === STATUS.PENDING) {
if (value instanceof MyPromise) {
value.then(this.onResolve, this.onReject);
return;
}
this.status = STATUS.RESOLVED;
this.value = value;
this.runCallbacks();
}
}
onReject(value) {
if (this.status === STATUS.PENDING) {
if (value instanceof MyPromise) {
value.then(this.onResolve, this.onReject);
return;
}
this.#status = STATUS.REJECTED;
this.#value = value;
this.runCallbacks();
}
}
runCallbacks() {
if (this.status === STATUS.RESOLVED) {
this.onResolveCallbacks.forEach((fn) => fn(this.value));
this.onResolveCallbacks = [];
} else if (this.status === STATUS.REJECTED) {
this.onRejectCallbacks.forEach((fn) => fn(this.value));
this.onRejectCallbacks = [];
}
}
/**
*
* @param {*} onResolveCallback
* @param {*} onRejectCallback
* @returns MyPromise
*/
then(onResolveCallback, onRejectCallback) {
return new Promise((resolve, reject) => {
this.onResolveCallbacks.push((result) => {
if (onResolveCallback == null) {
resolve(result);
return;
}
try {
const resolvedFromLastPromise = onResolveCallback(
this.value
);
if (resolvedFromLastPromise instanceof MyPromise) {
resolvedFromLastPromise.then(resolve, reject);
} else {
resolve(onResolveCallback(this.value));
}
} catch (err) {
reject(err);
}
});
this.onRejectCallbacks.push((result) => {
if (onRejectCallback == null) {
reject(result);
return;
}
try {
const rejectedFromLastPromise = onRejected(this.value);
if (rejectedFromLastPromise instanceof Promise) {
rejectedFromLastPromise.then(resolve, reject);
} else {
reject(rejectedFromLastPromise);
}
} catch (err) {
reject(err);
}
});
this.runCallbacks();
});
}
catch(callback) {
return this.then(undefined, callback);
}
finally(callback) {
return this.then(
(result) => {
callback();
return result;
},
(result) => {
callback();
throw result;
}
);
}
static all(promises) {
const res = [];
let completed = 0;
return new MyPromise((resolve, reject) => {
for (let i = 0; i < promises.length; i++) {
const promise = promises[i];
promise
.then((value) => {
completed++;
res[i] = value;
if (completed === promises.length) {
resolve(res);
}
})
.catch(reject);
}
});
}
}