Master the JavaScript Interview: What is a Promise?

Photo by Kabun (CC BY NC SA 2.0)

“Master the JavaScript Interview” is a series of posts designed to prepare candidates for common questions they are likely to encounter when applying for a mid to senior-level JavaScript position. These are questions I frequently use in real interviews.

What is a Promise?

A promise is an object that may produce a single value some time in the future: either a resolved value, or a reason that it’s not resolved (e.g., a network error occurred). A promise may be in one of 3 possible states: fulfilled, rejected, or pending. Promise users can attach callbacks to handle the fulfilled value or the reason for rejection.

Promises are eager, meaning that a promise will start doing whatever task you give it as soon as the promise constructor is invoked. If you need lazy, check out observables or tasks.

An Incomplete History of Promises

Early implementations of promises and futures (a similar / related idea) began to appear in languages such as MultiLisp and Concurrent Prolog as early as the 1980’s. The use of the word “promise” was coined by Barbara Liskov and Liuba Shrira in 1988[1].

The first time I heard about promises in JavaScript, Node was brand new and the community was discussing the best way to handle asynchronous behavior. The community experimented with promises for a while, but eventually settled on the Node-standard error-first callbacks.

Around the same time, Dojo added promises via the Deferred API. Growing interest and activity eventually led to the newly formed Promises/A specification designed to make various promises more interoperable.

jQuery’s async behaviors were refactored around promises. jQuery’s promise support had remarkable similarities to Dojo’s Deferred, and it quickly became the most commonly used promise implementation in JavaScript due to jQuery’s immense popularity?—?for a time. However, it did not support the two channel (fulfilled/rejected) chaining behavior & exception management that people were counting on to build tools on top of promises.

In spite of those weaknesses, jQuery officially made JavaScript promises mainstream, and better stand-alone promise libraries like Q, When, and Bluebird became very popular. jQuery’s implementation incompatibilities motivated some important clarifications in the promise spec, which was rewritten and rebranded as the Promises/A+ specification.

ES6 brought a Promises/A+ compliant Promise global, and some very important APIs were built on top of the new standard Promise support: notably the WHATWG Fetch spec and the Async Functions standard (a stage 3 draft at the time of this writing).

The promises described here are those which are compatible with the Promises/A+ specification, with a focus on the ECMAScript standard Promise implementation.

How Promises Work

A promise is an object which can be returned synchronously from an asynchronous function. It will be in one of 3 possible states:

  • Fulfilled: onFulfilled() will be called (e.g., resolve() was called)
  • Rejected: onRejected() will be called (e.g., reject() was called)
  • Pending: not yet fulfilled or rejected

A promise is settled if it’s not pending (it has been resolved or rejected). Sometimes people use resolved and settled to mean the same thing: not pending.

Once settled, a promise can not be resettled. Calling resolve() or reject()again will have no effect. The immutability of a settled promise is an important feature.

Native JavaScript promises don’t expose promise states. Instead, you’re expected to treat the promise as a black box. Only the function responsible for creating the promise will have knowledge of the promise status, or access to resolve or reject.

Here is a function that returns a promise which will resolve after a specified time delay:

const wait = time => new Promise((resolve) => setTimeout(resolve, time));

wait(3000).then(() => console.log('Hello!')); // 'Hello!'

wait?—?promise example on CodePen
Our wait(3000) call will wait 3000ms (3 seconds), and then log 'Hello!'. All spec-compatible promises define a .then() method which you use to pass handlers which can take the resolved or rejected value.

The ES6 promise constructor takes a function. That function takes two parameters, resolve(), and reject(). In the example above, we’re only using resolve(), so I left reject() off the parameter list. Then we call setTimeout() to create the delay, and call resolve() when it’s finished.

You can optionally resolve() or reject() with values, which will be passed to the callback functions attached with .then().

When I reject() with a value, I always pass an Error object. Generally I want two possible resolution states: the normal happy path, or an exception?—?anything that stops the normal happy path from happening. Passing an Error object makes that explicit.

Important Promise Rules

A standard for promises was defined by the Promises/A+ specificationcommunity. There are many implementations which conform to the standard, including the JavaScript standard ECMAScript promises.

Promises following the spec must follow a specific set of rules:

  • A promise or “thenable” is an object that supplies a standard-compliant .then() method.
  • A pending promise may transition into a fulfilled or rejected state.
  • A fulfilled or rejected promise is settled, and must not transition into any other state.
  • Once a promise is settled, it must have a value (which may be undefined). That value must not change.

Change in this context refers to identity (===) comparison. An object may be used as the fulfilled value, and object properties may mutate.

Every promise must supply a .then() method with the following signature:

promise.then(
  onFulfilled?: Function,
  onRejected?: Function
) => Promise

The .then() method must comply with these rules:

Both onFulfilled() and onRejected() are optional.
If the arguments supplied are not functions, they must be ignored.
onFulfilled() will be called after the promise is fulfilled, with the promise’s value as the first argument.
onRejected() will be called after the promise is rejected, with the reason for rejection as the first argument. The reason may be any valid JavaScript value, but because rejections are essentially synonymous with exceptions, I recommend using Error objects.
Neither onFulfilled() nor onRejected() may be called more than once.
.then() may be called many times on the same promise. In other words, a promise can be used to aggregate callbacks.
.then() must return a new promise, promise2.
If onFulfilled() or onRejected() return a value x, and x is a promise, promise2 will lock in with (assume the same state and value as) x. Otherwise, promise2 will be fulfilled with the value of x.
If either onFulfilled or onRejected throws an exception e, promise2 must be rejected with e as the reason.
If onFulfilled is not a function and promise1 is fulfilled, promise2 must be fulfilled with the same value as promise1.
If onRejected is not a function and promise1 is rejected, promise2 must be rejected with the same reason as promise1.
Promise Chaining
Because .then() always returns a new promise, it’s possible to chain promises with precise control over how and where errors are handled. Promises allow you to mimic normal synchronous code’s try/catch behavior.

Like synchronous code, chaining will result in a sequence that runs in serial. In other words, you can do:

fetch(url)
  .then(process)
  .then(save)
  .catch(handleErrors)
;

Assuming each of the functions, fetch(), process(), and save() return promises, process() will wait for fetch() to complete before starting, and save() will wait for process() to complete before starting. handleErrors() will only run if any of the previous promises reject.

Here’s an example of a complex promise chain with multiple rejections:

const wait = time => new Promise(
  res => setTimeout(() => res(), time)
);

wait(200)
  // onFulfilled() can return a new promise, `x`
  .then(() => new Promise(res => res('foo')))
  // the next promise will assume the state of `x`
  .then(a => a)
  // Above we returned the unwrapped value of `x`
  // so `.then()` above returns a fulfilled promise
  // with that value:
  .then(b => console.log(b)) // 'foo'
  // Note that `null` is a valid promise value:
  .then(() => null)
  .then(c => console.log(c)) // null
  // The following error is not reported yet:
  .then(() => {throw new Error('foo');})
  // Instead, the returned promise is rejected
  // with the error as the reason:
  .then(
    // Nothing is logged here due to the error above:
    d => console.log(`d: ${ d }`),
    // Now we handle the error (rejection reason)
    e => console.log(e)) // [Error: foo]
  // With the previous exception handled, we can continue:
  .then(f => console.log(`f: ${ f }`)) // f: undefined
  // The following doesn't log. e was already handled,
  // so this handler doesn't get called:
  .catch(e => console.log(e))
  .then(() => { throw new Error('bar'); })
  // When a promise is rejected, success handlers get skipped.
  // Nothing logs here because of the 'bar' exception:
  .then(g => console.log(`g: ${ g }`))
  .catch(h => console.log(h)) // [Error: bar]
;

Promise chaining behavior example on CodePen
Error Handling
Note that promises have both a success and an error handler, and it’s very common to see code that does this:

save().then(
  handleSuccess,
  handleError
);

But what happens if handleSuccess() throws an error? The promise returned from .then() will be rejected, but there’s nothing there to catch the rejection?—?meaning that an error in your app gets swallowed. Oops!

For that reason, some people consider the code above to be an anti-pattern, and recommend the following, instead:

save()
  .then(handleSuccess)
  .catch(handleError)
;

The difference is subtle, but important. In the first example, an error originating in the save() operation will be caught, but an error originating in the handleSuccess() function will be swallowed.


Without .catch(), an error in the success handler is uncaught.

In the second example, .catch() will handle rejections from either save(), or handleSuccess().


With .catch(), both error sources are handled. ([diagram source])

Of course, the save() error might be a networking error, whereas the handleSuccess() error may be because the developer forgot to handle a specific status code. What if you want to handle them differently? You could opt to handle them both:

save()
  .then(
    handleSuccess,
    handleNetworkError
  )
  .catch(handleProgrammerError)
;

Whatever you prefer, I recommend ending all promise chains with a .catch(). That’s worth repeating:

I recommend ending all promise chains with a .catch().

How Do I Cancel a Promise?

One of the first things new promise users often wonder about is how to cancel a promise. Here’s an idea: Just reject the promise with “Cancelled” as the reason. If you need to deal with it differently than a “normal” error, do your branching in your error handler.

Here are some common mistakes people make when they roll their own promise cancellation:

Adding .cancel() to the promise

Adding .cancel() makes the promise non-standard, but it also violates another rule of promises: Only the function that creates the promise should be able to resolve, reject, or cancel the promise. Exposing it breaks that encapsulation, and encourages people to write code that manipulates the promise in places that shouldn't know about it. Avoid spaghetti and broken promises.

Forgetting to clean up

Some clever people have figured out that there’s a way to use Promise.race()as a cancellation mechanism. The problem with that is that cancellation control is taken from the function that creates the promise, which is the only place that you can conduct proper cleanup activities, such as clearing timeouts or freeing up memory by clearing references to data, etc...

Forgetting to handle a rejected cancel promise

Did you know that Chrome throws warning messages all over the console when you forget to handle a promise rejection? Oops!

Overly complex

The withdrawn TC39 proposal for cancellation proposed a separate messaging channel for cancellations. It also used a new concept called a cancellation token. In my opinion, the solution would have considerably bloated the promise spec, and the only feature it would have provided that speculations don’t directly support is the separation of rejections and cancellations, which, IMO, is not necessary to begin with.

Will you want to do switching depending on whether there is an exception, or a cancellation? Yes, absolutely. Is that the promise’s job? In my opinion, no, it’s not.

Rethinking Promise Cancellation

Generally, I pass all the information the promise needs to determine how to resolve / reject / cancel at promise creation time. That way, there’s no need for a .cancel() method on a promise. You might be wondering how you could possibly know whether or not you’re going to cancel at promise creation time.

“If I don’t yet know whether or not to cancel, how will I know what to pass in when I create the promise?”

If only there were some kind of object that could stand in for a potential value in the future… oh, wait.

The value we pass in to represent whether or not to cancel could be a promise itself. Here’s how that might look:

const wait = (
  time,
  cancel = Promise.reject()
) => new Promise((resolve, reject) => {
  const timer = setTimeout(resolve, time);
  const noop = () => {};

  cancel.then(() => {
    clearTimeout(timer);
    reject(new Error('Cancelled'));
  }, noop);
});

const shouldCancel = Promise.resolve(); // Yes, cancel
// const shouldCancel = Promise.reject(); // No cancel

wait(2000, shouldCancel).then(
  () => console.log('Hello!'),
  (e) => console.log(e) // [Error: Cancelled]
); 

We’re using default parameter assignment to tell it not to cancel by default. That makes the cancel parameter conveniently optional. Then we set the timeout as we did before, but this time we capture the timeout’s ID so that we can clear it later.

We use the cancel.then() method to handle the cancellation and resource cleanup. This will only run if the promise gets cancelled before it has a chance to resolve. If you cancel too late, you’ve missed your chance. That train has left the station.

Note: You may be wondering what the noop() function is for. The word noop stands for no-op, meaning a function that does nothing. Without it, V8 will throw warnings: UnhandledPromiseRejectionWarning: Unhandled promise rejection. It’s a good idea to always handle promise rejections, even if your handler is a noop().
Abstracting Promise Cancellation
This is fine for a wait() timer, but we can abstract this idea further to encapsulate everything you have to remember:

  1. Reject the cancel promise by default?—?we don’t want to cancel or throw errors if no cancel promise gets passed in.
  2. Remember to perform cleanup when you reject for cancellations.
  3. Remember that the onCancel cleanup might itself throw an error, and that error will need handling, too. (Note that error handling is omitted in the wait example above?—?it’s easy to forget!)
    Let’s create a cancellable promise utility that you can use to wrap any promise. For example, to handle network requests, etc… The signature will look like this:
speculation(fn: SpecFunction, shouldCancel: Promise) => Promise

The SpecFunction is just like the function you would pass into the Promise constructor, with one exception?—?it takes an onCancel() handler:

SpecFunction(resolve: Function, reject: Function, onCancel: Function) => Void
// HOF Wraps the native Promise API
// to add take a shouldCancel promise and add
// an onCancel() callback.
const speculation = (
  fn,
  cancel = Promise.reject() // Don't cancel by default
) => new Promise((resolve, reject) => {
  const noop = () => {};

  const onCancel = (
    handleCancel
  ) => cancel.then(
      handleCancel,
      // Ignore expected cancel rejections:
      noop
    )
    // handle onCancel errors
    .catch(e => reject(e))
  ;

  fn(resolve, reject, onCancel);
});

Note that this example is just an illustration to give you the gist of how it works. There are some other edge cases you need to take into consideration. For example, in this version, handleCancel will be called if you cancel the promise after it is already settled.

I’ve implemented a maintained production version of this with edge cases covered as the open source library, Speculation.

Let’s use the improved library abstraction to rewrite the cancellable wait()utility from before. First install speculation:

npm install --save speculation

Now you can import and use it:

import speculation from 'speculation';

const wait = (
  time,
  cancel = Promise.reject() // By default, don't cancel
) => speculation((resolve, reject, onCancel) => {
  const timer = setTimeout(resolve, time);

  // Use onCancel to clean up any lingering resources
  // and then call reject(). You can pass a custom reason.
  onCancel(() => {
    clearTimeout(timer);
    reject(new Error('Cancelled'));
  });
}, cancel); // remember to pass in cancel!

wait(200, wait(500)).then(
  () => console.log('Hello!'),
  (e) => console.log(e)
); // 'Hello!'

wait(200, wait(50)).then(
  () => console.log('Hello!'),
  (e) => console.log(e)
); // [Error: Cancelled]

This simplifies things a little, because you don’t have to worry about the noop(), catching errors in your onCancel(), function or other edge cases. Those details have been abstracted away by speculation(). Check it out and feel free to use it in real projects.

Extras of the Native JS Promise

The native Promise object has some extra stuff you might be interested in:

  • Promise.reject() returns a rejected promise.
  • Promise.resolve() returns a resolved promise.
  • Promise.race() takes an array (or any iterable) and returns a promise that resolves with the value of the first resolved promise in the iterable, or rejects with the reason of the first promise that rejects.
  • Promise.all() takes an array (or any iterable) and returns a promise that resolves when all of the promises in the iterable argument have resolved, or rejects with the reason of the first passed promise that rejects.

Conclusion

Promises have become an integral part of several idioms in JavaScript, including the WHATWG Fetch standard used for most modern ajax requests, and the Async Functions standard used to make asynchronous code look synchronous.

Async functions are stage 3 at the time of this writing, but I predict that they will soon become a very popular, very commonly used solution for asynchronous programming in JavaScript?—?which means that learning to appreciate promises is going to be even more important to JavaScript developers in the near future.

For instance, if you’re using Redux, I suggest that you check out redux-saga: A library used to manage side-effects in Redux which depends on async functions throughout the documentation.

I hope even experienced promise users have a better understanding of what promises are and how they work, and how to use them better after reading this.

Explore the Series


  1. Barbara Liskov; Liuba Shrira (1988). “Promises: Linguistic Support for Efficient Asynchronous Procedure Calls in Distributed Systems”. Proceedings of the SIGPLAN ’88 Conference on Programming Language Design and Implementation; Atlanta, Georgia, United States, pp. 260–267. ISBN 0–89791–269–1, published by ACM. Also published in ACM SIGPLAN Notices, Volume 23, Issue 7, July 1988.

Level Up Your Skills with Live 1:1 Mentorship

DevAnywhere is the fastest way to level up to advanced JavaScript skills:

  • Live lessons
  • Flexible hours
  • 1:1 mentorship
  • Build real production apps

原文鏈接:Master the JavaScript Interview: What is a Promise?

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末押桃,一起剝皮案震驚了整個濱河市噩茄,隨后出現(xiàn)的幾起案子求豫,更是在濱河造成了極大的恐慌,老刑警劉巖暮现,帶你破解...
    沈念sama閱讀 219,110評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件留美,死亡現(xiàn)場離奇詭異竿音,居然都是意外死亡冯键,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,443評論 3 395
  • 文/潘曉璐 我一進(jìn)店門玩徊,熙熙樓的掌柜王于貴愁眉苦臉地迎上來租悄,“玉大人,你說我怎么就攤上這事恩袱∑澹” “怎么了?”我有些...
    開封第一講書人閱讀 165,474評論 0 356
  • 文/不壞的土叔 我叫張陵畔塔,是天一觀的道長潭辈。 經(jīng)常有香客問我,道長澈吨,這世上最難降的妖魔是什么把敢? 我笑而不...
    開封第一講書人閱讀 58,881評論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮谅辣,結(jié)果婚禮上修赞,老公的妹妹穿的比我還像新娘。我一直安慰自己桑阶,他們只是感情好柏副,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,902評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著蚣录,像睡著了一般割择。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上萎河,一...
    開封第一講書人閱讀 51,698評論 1 305
  • 那天荔泳,我揣著相機(jī)與錄音,去河邊找鬼虐杯。 笑死玛歌,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的擎椰。 我是一名探鬼主播沾鳄,決...
    沈念sama閱讀 40,418評論 3 419
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼确憨!你這毒婦竟也來了译荞?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,332評論 0 276
  • 序言:老撾萬榮一對情侶失蹤休弃,失蹤者是張志新(化名)和其女友劉穎吞歼,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體塔猾,經(jīng)...
    沈念sama閱讀 45,796評論 1 316
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡篙骡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,968評論 3 337
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了丈甸。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片糯俗。...
    茶點(diǎn)故事閱讀 40,110評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖睦擂,靈堂內(nèi)的尸體忽然破棺而出得湘,到底是詐尸還是另有隱情,我是刑警寧澤顿仇,帶...
    沈念sama閱讀 35,792評論 5 346
  • 正文 年R本政府宣布淘正,位于F島的核電站,受9級特大地震影響臼闻,放射性物質(zhì)發(fā)生泄漏鸿吆。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,455評論 3 331
  • 文/蒙蒙 一述呐、第九天 我趴在偏房一處隱蔽的房頂上張望惩淳。 院中可真熱鬧,春花似錦乓搬、人聲如沸思犁。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,003評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽抒倚。三九已至,卻和暖如春坷澡,著一層夾襖步出監(jiān)牢的瞬間托呕,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,130評論 1 272
  • 我被黑心中介騙來泰國打工频敛, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留项郊,地道東北人。 一個月前我還...
    沈念sama閱讀 48,348評論 3 373
  • 正文 我出身青樓斟赚,卻偏偏與公主長得像着降,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子拗军,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,047評論 2 355

推薦閱讀更多精彩內(nèi)容