PromiseはES2015から導入されたJavaScriptの言語機能で、非同期処理を表すオブジェクトです。基本的な使い方としては、まずfetchなどの非同期的な処理の結果としてPromiseオブジェクトが得られます。それに対してthenメソッドなどを用いることで、非同期処理が終わった後の次の処理を登録できます。また、ES2017で追加されたasync/awaitの構文はPromiseを便利に扱うためのものです。これはJavaScriptに独特の概念ではなく、C#ではTask、RustではFutureなどと呼ばれているようです。
JavaScriptを使っている多くの方は、Promiseを多かれ少なかれ使ったことがあるでしょう。そして、Promiseには結果が2種類、すなわち成功と失敗があることをご存知かと思います。このように、Promiseには状態があります。成功した状態、失敗した状態の他に、処理待ちの状態(まだ成功か失敗か分からない状態)があるのも分かりますね。
この記事では、Promiseの状態に関する用語を説明します。具体的には、pending、fulfilled、rejected、settled、resolvedを扱います。仕様書でいうと25.6 Promise Objectsの冒頭に書いてある内容です。
背景: Promise.allSettled
いきなり用語が英語になりましたが、別に英語力で皆さんを圧倒したいわけではありません。この記事の背景には、Promise.allSettledがあります。これはECMAScriptのプロポーザルの1つで、2019年1月のTC39ミーティングでStage2に昇格しました。つまり、多分数年後くらいにはPromise.allSettledがJavaScriptに追加される可能性が高いということです。
このPromise.allSettledは、Promise.allやPromise.raceの仲間です。引数にPromiseの配列を受け取り、全てのPromiseの結果が出るまで待つという動作をします。Promise.allとの違いは、渡されたPromiseのいずれかが失敗した場合に表れます。Promise.allはその場合即座に全体が失敗するのに対し、Promise.allSettledはその場合でも渡された全てのPromiseの結果が出るまで待ちます。詳細な動作は実装例を見てみると分かりやすいでしょう。Promise.allSettledはおおよそ次のように実装することができます(まだプロポーザルなので、今後仕様が変化する可能性があります)。
Promise.allSettled = promises =>
Promise.all(
promises.map(p =>
p.then(
result => ({ status: "fulfilled", value: result }),
error => ({ status: "rejected", reason: error })
)
)
);
この例を見て分かる通り、Promise.allSettledの結果は必ず成功となり、オブジェクトの配列を返します。各オブジェクトは渡されたそれぞれのPromiseの結果を表しており、成功の場合は{ status: "fulfilled", value: 値 }という形のオブジェクトが、失敗の場合は{ status: "rejected", reason: エラーオブジェクト }という形のオブジェクトが返ります。
実際に使ってみましょう。
Promise.allSettled([
fetch('/'),
fetch('https://qiita.com/'),
fetch('https://nonexistend.invalid/'),
]).then(console.log);
実行してみるとこんな感じになります(https://qiita.com/ で実行)。
Promise.allSettledに3つのPromiseを渡した結果、配列が得られました。先ほど説明したように、それぞれのPromiseが成功したのか失敗したのかがstatusプロパティに入っている文字列で判別可能になっています。
ここでのポイントは、statusプロパティに入っている"fulfilled"または"rejected"という文字列です。前者が成功したPromiseの結果、後者が失敗したPromiseの結果に対応します。このPromise.allSettledを使いたいときに、いちいちどちらがどちらなのか調べるのはなんとも馬鹿らしいですね。最低限、この2つの用語は覚えておきたいものです。
直感的な説明
では、本題である用語の説明に入ります。まず直感的な説明をします。
fulfilled, rejected, settled
先ほど出てきたfulfilledとrejectedは、既に結果が出ているPromiseの結果を表す用語です。fulfilledが成功した状態、rejectedは失敗した状態です。
また、Promise.allSettledの名前に使われているsettledというのは、fulfilledまたはrejectedである状態を指す言葉です。つまり、成功でも失敗でもいいからとにかく既に結果が出ているPromiseを指す言葉です。Promise.allSettledというのは、全てのPromiseがsettledになるまで待つという意味だったのですね。
pending
pendingは、まだ結果が出ていない状態のことです。つまり、settledでないPromiseはpendingです。
よく見ると、上の画像(Promise.allSettledの実行例)でPromise.allSettledの返り値にPromise {<pending>}と書いてありますね。これはPromise.allSettledが実行された瞬間はまだ結果が出ていないということを意味しています。
明らかに、全てのPromiseはpending、fulfilled、rejectedのいずれかです。つまり、まだ結果が出ていないか、成功という結果が出ているか、失敗という結果が出ているかのいずれかということですね。
resolved
しかし、まだひとつ解決していない用語が残っていますね。それがresolvedです。以上の3つ(settledを含めると4つ)のほかに、全てのPromiseはresolvedであるかそうでない(unresolvedである)かに分類できます。
直感的には、resolvedなPromiseというのは、既に自身の処理を終えているPromiseのことです。必然的に、settledなPromiseはresolvedですが、pendingなPromiseはresolvedな場合とunresolvedな場合があります。
自分の処理が終わらないと結果が決まりませんから、settledなPromiseはresolvedであるというのは分かりますね。問題はpendingな場合です。ポイントは、Promiseというのは自身の結果を他のPromiseに委譲することができるという点です。AというPromiseがBというPromiseに結果を委譲したがBがまだpendingな場合、Aはresolvedだがpendingであることになります。コードで例を見てみましょう。
const sleep = duration => new Promise((resolve, reject) => setTimeout(resolve, duration));
const p1 = new Promise((resolve, reject) => {
resolve(sleep(3000).then(()=> console.log('hi')));
});
ここで定義したsleepは引数で指定された時間の後に解決されるPromiseを返す関数です。注目していただきたいのはp1です。ご存知のように、Promiseコンストラクタを用いてPromiseを作る場合、コンストラクタに関数をひとつ渡します。その関数はresolveとrejectを受け取り、これを呼び出すことで作られたPromiseが解決(resolve)されます。resolveに値を渡した場合はその値がPromiseの結果(成功)となりますが、上の例のようにresolveに別のPromise(今回はsleep(3000).then(()=> console.log('hi')))を渡すことができます。これが委譲です。
つまり、sleep(3000).then(()=> console.log('hi'))がfulfilledになると同時にp1もfulfilledになります。今回の場合、p1がfulfilledになるのは3秒後ですね。p1は、作られると同時にresolvedになります。なぜなら、即座に別のPromiseに結果を委譲しているからです。しかし、委譲されたPromiseは3秒後までpendingなので、p1も同様に3秒後までpendingです。3秒後に委譲先のPromiseがfulfilledになると同時にp1もfulfilledになります。
なお、sleep(3000)は3秒後までunresolvedです。なぜなら、resolveが呼ばれるまでは結果が決まらないし委譲も行なわれないからです。お分かりのように、PromiseコンストラクタでPromiseを作る場合、resolveまたはrejectが呼ばれるまでがunresolvedな状態で、呼ぶとresolved(まだpendingのままかもしれないしsettledになるかもしれない)になります。
定義を見る
ここまで説明した用語は、冒頭でも触れたECMAScript仕様書に定義が載っています。
Any Promise object is in one of three mutually exclusive states: fulfilled, rejected, and pending:
A promise p is fulfilled if p.then(f, r) will immediately enqueue a Job to call the function f.
A promise p is rejected if p.then(f, r) will immediately enqueue a Job to call the function r.
A promise is pending if it is neither fulfilled nor rejected.
A promise is said to be settled if it is not pending, i.e. if it is either fulfilled or rejected.
A promise is resolved if it is settled or if it has been “locked in” to match the state of another promise. Attempting to resolve or reject a resolved promise has no effect. A promise is unresolved if it is not resolved. An unresolved promise is always in the pending state. A resolved promise may be pending, fulfilled or rejected.
つまり、おおよそ以下の定義となっています。
-
p.then(f, r)がすぐにfを呼び出すジョブを登録するならばpはfulfilledである。 -
p.then(f, r)がすぐにrを呼び出すジョブを登録するならばpはrejectedである。 - fulfilledでもrejectedでもないPromiseはpendingである。
- pendingでないPromiseはsettledである。
- settledなPromiseはresolvedである。また、状態が別のPromiseと一致するように“固定”されているPromiseもresolvedである。
特にfulfilledとrejectedの定義が特徴的です。「すぐにfを呼び出すジョブを登録する」というのがまたややこしい概念ですが、「ジョブを登録」という言い方をしていることから想像がつくように、fは同期的には呼び出されません。このことは次の例を実行してみると分かります。
Promise.resolve(3).then(console.log);
console.log('hi');
これを実行すると、
hi
3
という順番でログが表示されます。Promise.resolve(3)はすでにfulfilledなPromiseを返しますから、それに対してthenを呼び出すことで、console.log(3)を呼び出すジョブがすぐに登録されます。しかし、そのジョブは同期的ではないため、次の文console.log('hi')よりは後になります。今の実行が終わり次第、ジョブとして登録されたconsole.log(3)が実行されるのです。
これがどれくらいすぐかというと、setTimeout(fn, 0)とかよりもすぐです。
/*
* 3
* hi
* の順番で表示される
*/
Promise.resolve(3).then(console.log);
setTimeout(()=> console.log('hi'), 0);
非同期的に実行されたジョブの中からでも、すぐにジョブを登録すれば待っているタイマーに割り込むことができます。すぐというのはこれくらいすぐです。
/*
* 3
* 5
* hi
* の順番で表示される
*/
Promise.resolve(3).then(v=> {
Promise.resolve(5).then(console.log);
console.log(v);
});
setTimeout(()=> console.log('hi'), 0);
なお、仕様書的には一応、Promiseオブジェクトがfulfilled、rejected、pendingのどの状態であるかという情報を[[PromiseState]]内部スロットに保持しています(仕様書 25.6.6 Properties of Promise Instance)。実際、Promise.prototype.thenの定義を見ると、確かに[[PromiseState]]がfulfilledやrejectedの場合はすぐに当該関数を呼ぶという処理が書いてあります。
最後にクイズです。次のPromise p2, p3, p4が生成された直後の状態を分類してみてください。
const p2 = new Promise(resolve => {
resolve(Promise.resolve(123))
});
const p3 = new Promise(()=> {});
const p4 = Promise.resolve(p3);
答えは、p2がfulfilled、p3がunresolved、p4がresolvedかつpendingです。
まとめ
この記事ではPromiseの状態にまつわる用語であるfulfilled、rejected、pending、settled、そしてresolvedについて説明しました。Promise.allSettledの追加が現実味を帯びてきた今、これらの用語は今後身近になってくると思われます。
Q&A
-
Q. 何で
Promise.fulfillじゃなくてPromise.resolveなの? -
A. Promiseオブジェクトを渡したらfulfilledじゃなくてrejectedになる可能性があるからです(多分)。一方、
Promise.resolveの返り値のPromiseは必ずすでにresolvedです。 - Q. settledとかはいいとしても、長々と説明していたresolvedとか何の役に立つの?
- A. (目を逸らす)