読者です 読者をやめる 読者になる 読者になる

Promiseの失敗文脈を使ってはならない理由と組み込み関数では使っていい理由

TypeScript

Promiseのthenメソッドの第二引数およびcatchメソッドのコールバックの呼び出しで表現される失敗文脈、これは原則として使ってはならない。 ネイティブの組み込み関数が使っているからといって真似してPromise値を返し失敗文脈を持つ関数を作ってはならない。

Promiseの失敗文脈には任意の回復可能または予測された失敗と予期せぬ例外を区別できない(例外を強制的に失敗に戻す)欠陥がある。 このためPromiseの失敗文脈の型を定義しようという試みは基本的に無意味だ。 仮に失敗文脈に混入した例外を除去しようとすると以下のようになる。

Promise.resolve()
  .catch(reason => {
    if (reason instanceof Error) throw reason;
    // ...
  });

特定のエラー値を返す場合は以下のようになる。

Promise.resolve()
  .catch(reason => {
    if (reason instanceof Error && reason instanceof DOMError === false) throw reason;
    // ...
  });

とてもバカバカしい。すべての失敗文脈にこの一行が入ったJavaScriptの世界を想像すると頭が痛くなる。 早い話がnullの失敗を繰り返しているのだ。 よってPromiseは原則として失敗を成功文脈で扱い、失敗文脈は例外のみを扱う例外文脈として設計しなければならない。 成否はタプルによる多値で表現し、最初の要素に成否判定のフラグを入れるフォーマットを推奨する。 オブジェクトによる多値は個別に構造を記憶する無駄な労力を要し、生成コストも配列より高いため推奨しない。

new Promise(resolve => resolve([arr.length > 0, arr.pop(), arr]));

一方で組み込み関数が失敗文脈で失敗を表現することは必ずしも間違いではない。 組み込み関数は言語処理系が実装する関数ゆえに絶対に予期せぬ例外を混入させない設計と信頼性確保が可能だからである。 組み込み関数が失敗文脈に例外を含まない(特定のエラー値のみを返す場合を含む)と宣言したらそれは間違いなく含まないとみなせる。 成否表現のフォーマットを強制的に統一させるためといった都合もあるだろう。 同様の意図からライブラリの公開するインターフェイスが失敗文脈の使用を選択する場合もある。 しかしもし失敗と例外が入り混じった失敗文脈を作る関数があればそれはやはり設計が悪い。

対してユーザーの作る失敗文脈付き(Promise値を返す)関数で組み込み関数と同じ信頼性の関数を作ることは容易ではない。 コードサイズが大きくなればなるほどバグにより例外が混入するリスクが増える。 よって失敗文脈を使用する関数を新たに作る場合、その関数は失敗文脈の信頼性を確保するために、低水準で最小単位の関数でなければならない。

単に非同期だからPromiseを使う、Promiseだから失敗を失敗文脈に分離するといった思考論法で作られたPromiseチェインが、要求される失敗文脈の信頼性を自然と備えていることは期待できない。 ゆえに、Promise値を返す組み込み関数を模倣して自身で失敗文脈を持つPromiseチェインや関数を作ることは基本的にアンチパターンである。

async/awaitにおいても同じポリシーが適用でき、async/awaitは原則としてtry-catch文を使用せずに使えるように設計すべきである。 Promiseの失敗文脈を例外文脈として設計した場合、async/awaitのtry-catch文が自然に適合することにも注目してもらいたい。

また、awaitを囲むtryブロックにはawait以外の同期的処理を含めずawaitの失敗文脈の捕捉のみに責務を限定すべきである。 同期処理において捕捉しない例外を非同期処理に限って捕捉しなければならない理由はない。

await sleep(1);
var n = 100;
try {
  await sleep(n);
}
catch (err) {
  throw err;
}