👋

イベントループと TypeScript の型から理解する非同期処理

2022/04/21に公開

この本は、ブルーベリー本の 8 章からインスパイアされて、 TS の型が示す情報から Promise というものを理解してみる、というアプローチで書いたJSの非同期処理の解説です。

これらの資料と合わせて読むことを推奨します。

https://zenn.dev/estra/articles/js-async-programming-roadmap
https://azu.github.io/promises-book/

JSのイベントループのイメージを掴む

JSでは中々意識することが少ないですが、正しく理解するには OS レベルのスレッドの視点で考え始める必要があります。

ブラウザや Node.js では一つのスクリプト実行単位を1つのスレッドに割り当てます。それをメインスレッドと呼んだり、ブラウザだったら UI スレッドと呼んだりします。

例えばブラウザでは、これは秒間60回、つまり 16.6ms ごとにループを呼び出します。(node だったらこれがもっと短いです)

仮に setTimeout の実装がなかったとして、それ相当の擬似コードを書くのを試みます。

let handlers = new Set();

function wait(ms: number) {
  // ...与えられた ms 待機する
}

// 簡略化のために timeoutId は略
function setTimeout(fn, ms) {
  const now = Date.now();
  const handler = () => {
    if (now - timer >= ms) {
      fn();
      handlers.delete(handler);
    }
  }
  handlers.add(handler);
}

function mainloop(){
  while(true) {
    wait(16);
    handlers.forEach(fn => fn());
  }
}

mainloop();

mainloop が特定時間の経過ごとに handlers の中身を確認して実行します。

このコードの中で wait 関数は実装していません。さて問題です。

setTimeout を使わずに wait 関数は実装できるか?

答えは「理論上可能だが、問題がある実装しかできない」となります。

というわけで、お行儀が悪い実装をします。

  // ...与えられた ms 待機する
function wait(ms: number) {
  const start = Date.now();
  while (true) {
    if (Date.now() - start >= ms) {
      return;
    }
  }
}

while でループごとに経過時間を参照して、一定時間を超えていればループを開放します。これはスピンロックといって、(マルチスレッドやスケジューラを考慮しない環境では)スレッドを完全に専有する実装になります。

スピンロック - Wikipedia

ちょっと勘のいい人ならわかると思いますが、 Date.now() は意外と重い処理で、時間を参照する重い処理を何十万回も実行すると露骨にCPUが重くなります。performance.now() というプロセス時間のタイマーもありますが、同様です。

追記: これはスピンロックというより、ビジーウェイトの問題でしたね…。

このままだと、タブの数だけスピンロックが発生して、CPU を専有してしまう可能性があります。OSごとガクガクになっていくか、ブラウザがクラッシュするでしゅ。

なので、効率がよい実装のために、OS レベルの操作をする必要があります。

    1. 現在のハンドラーの実行が終わったらカーネルにスレッドを止めてもらう
    1. 指定時間後にプロセスを再開し、予約された処理を実行する
    1. 1に戻る

これによって、大量のプロセスを一つのスレッドでさばくことが可能になります。この実装をイベントループといい、ブラウザや node.js 以外では nginx もこの実装ですね。他のサーバーサイドの言語も、現代ではだいたいこの実行方式を持っています。c10k 対策なんて行ってた時代もありました。

イベントループ - Wikipedia

スレッドの停止・再開というレイヤに踏み込むには、OSに直接システムコールを行う必要があり、これを実現するには自分自身のイベントループを操作する「特権」を必要とします。これがJSの仕様だけでは実現できない部分です。例えば Deno だったら、ここが tokio + Rust で実装されています。

人間の視点では 16ms ごとに停止・再開を繰り返すのは非効率に思えますが、現代のCPUはナノ秒やそれ以下のオーダーで動いてるのと、実際にはブラウザでは常に60FPSで動いてるわけではなく、フォーカスを持ってないバックグラウンドプロセスになるとこれがもっと長い時間に設定されます。これによって、ブラウザでタブを50個開いても即死はしてないわけです。(重くなるのはそうですが…)

ちょっと脇道にそれましたが、JSの時間関係のAPI、 setTimeout / new Promise() / await ... はサンプルコードの handlers に処理を予約するAPI、というイメージを持っておくと、Promise や async/await の理解が簡単になります。

(厳密には setTimeout/setInterval のような timer 系と Promise は別の単位で管理されています)

Using microtasks in JavaScript with queueMicrotask() - Web API | MDN

余談: ブラウザの requestAnimationFrame / requestIdleCallback について

ブラウザのイベントループはおよそ 16ms ごとに実行されるといっても、各イベントループ自体の処理が 16ms を超過することがありえます。このとき、相対的にスレッドを専有する時間が増えていきます。ユーザーとしては「入力に応答しないラグ」として感じられます。

この対策のため requestIdleCallback は時間指定ではなく、CPU が待機状態になったら実行する、というAPI があったりします。

requestIdleCallback - Web API | MDN

また、ブラウザでは CPU の処理時間とは別に、画面のレンダリングに関するイベントループがあり、requestAnimationFrame は非同期キューを「画面を更新する前に」呼び出します。これにより、状態に応じて画面を書き換える処理への負担を減らします。

Window.requestAnimationFrame() - Web API | MDN

Promise をちゃんと押さえる

ここからやっとJSとしての非同期の話になります。まずPromiseから理解していきましょう。

Promise を抑えずに async/await を正しく理解することは出来ません。仮に自分は Promise を経ずにちゃんとasync/await理解している、と思っている人がいたら、残念ながら、それは確実に間違っています。

まず、promise で 「1000ms 後に 0.5 以上の number を返すか、失敗する」 というコードを書いてみます。

const p = new Promise<number>((resolve, reject) => {
  setTimeout(() => {
    const n = Math.random()
    if (n > 0.5) {
      resolve(n);
    } else {
      reject();
    }
  }, 1000);
});

p.then((n) => {
  console.log('over 0.5', n);
});

p.catch((err) => {
  console.log(err)
});

p は 「1000ms 後に 0.5 以上の number を返すか、失敗する」という Promise であり、ここでは成功時の .then() と 失敗時の .catch() という2つの分岐に対する処理を記述しています。

Promise<T> は、その実装の中身に関係なく、いずれ T に解決されるか、実行に失敗する非同期オブジェクトです。Promiseの抽象化のキモは、処理の中身を知らないまま、解決される値に関心を分離する、というところにあります。

Promiseの内側では、resolve(n) を呼ぶと値を返して正常終了し、reject() が呼ばれると失敗を表現します。

呼び出し側では、 resolve に対応する .then(...), reject に対応する .catch(...), ついでに言っておくと、成功でも失敗でも構わず終わったら実行される .finally(...) があります。

TS の Promise 型をみる

次に Promise の型を見てみましょう。

new Promise で使われる方を見ます。あまり見ない書き方ですが、interface にコンストラクタを宣言する際は、 new (args: Arg): ReturnType のようになります。

typescript/lib/lib.es2015.d.ts
interface PromiseConstructor {
    /**
     * A reference to the prototype.
     */
    readonly prototype: Promise<any>;

    /**
     * Creates a new Promise.
     * @param executor A callback used to initialize the promise. This callback is passed two arguments:
     * a resolve callback used to resolve the promise with a value or the result of another promise,
     * and a reject callback used to reject the promise with a provided reason or error.
     */
    new <T>(executor: (resolve: (value: T | PromiseLike<T>) => void, reject: (reason?: any) => void) => void): Promise<T>;

    /**
     * Creates a Promise that is resolved with an array of results when all of the provided Promises
     * resolve, or rejected when any Promise is rejected.
     * @param values An array of Promises.
     * @returns A new Promise.
     */
    all<T extends readonly unknown[] | []>(values: T): Promise<{ -readonly [P in keyof T]: Awaited<T[P]> }>;

    // see: lib.es2015.iterable.d.ts
    // all<T>(values: Iterable<T | PromiseLike<T>>): Promise<T[]>;

    /**
     * Creates a Promise that is resolved or rejected when any of the provided Promises are resolved
     * or rejected.
     * @param values An array of Promises.
     * @returns A new Promise.
     */
    race<T extends readonly unknown[] | []>(values: T): Promise<Awaited<T[number]>>;

    // see: lib.es2015.iterable.d.ts
    // race<T>(values: Iterable<T>): Promise<T extends PromiseLike<infer U> ? U : T>;

    /**
     * Creates a new rejected promise for the provided reason.
     * @param reason The reason the promise was rejected.
     * @returns A new rejected Promise.
     */
    reject<T = never>(reason?: any): Promise<T>;

    /**
     * Creates a new resolved promise.
     * @returns A resolved promise.
     */
    resolve(): Promise<void>;

    /**
     * Creates a new resolved promise for the provided value.
     * @param value A promise.
     * @returns A promise whose internal state matches the provided promise.
     */
    resolve<T>(value: T | PromiseLike<T>): Promise<T>;
}

最初に注目するのは

new <T>(executor: (resolve: (value: T | PromiseLike<T>) => void, reject: (reason?: any) => void) => void): Promise<T>;

の部分です。resolve, reject の引数を2つ持つ関数を受けて、Promise<T> を返します。

/**
 * Represents the completion of an asynchronous operation
 */
interface Promise<T> {
    /**
     * Attaches callbacks for the resolution and/or rejection of the Promise.
     * @param onfulfilled The callback to execute when the Promise is resolved.
     * @param onrejected The callback to execute when the Promise is rejected.
     * @returns A Promise for the completion of which ever callback is executed.
     */
    then<TResult1 = T, TResult2 = never>(onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null): Promise<TResult1 | TResult2>;

    /**
     * Attaches a callback for only the rejection of the Promise.
     * @param onrejected The callback to execute when the Promise is rejected.
     * @returns A Promise for the completion of the callback.
     */
    catch<TResult = never>(onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null): Promise<T | TResult>;
}

ここで意識すべきは、 Promise<T> は正常系の値を宣言できるだけで、 .catch() 側の型を書けないという点です。

Promise コールバックの中では、内部で発生する例外と reject(err) が意図して混同されます。

const p = new Promise<number>((resolve, reject) => {
  (null as any).foo = 1;
  return resolve(Math.random());
});
p.catch((err) => {
  console.error('catched', err);
});
$ npx ts-node -T promise.ts
catched TypeError: Cannot set properties of null (setting 'foo')
    at promise.ts:21:20
    at new Promise (<anonymous>)
    at Object.<anonymous> (promise.ts:20:11)
    at Module._compile (node:internal/modules/cjs/loader:1101:14)
    at Module.m._compile (...node_modules/ts-node/src/index.ts:1043:23)
    at Module._extensions..js (node:internal/modules/cjs/loader:1153:10)
    at Object.require.extensions.<computed> [as .ts] (...node_modules/ts-node/src/index.ts:1046:12)
    at Module.load (node:internal/modules/cjs/loader:981:32)
    at Function.Module._load (node:internal/modules/cjs/loader:822:12)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:81:12)

エラーメッセージの冒頭に自分で足した catched がついてることからわかるように、通常の throw や実行時例外 も try catch ではなく、 catch 側で捕捉されます。このとき、err は reject で明示的に渡された値なのか、他の例外なのかが不明です。

err が何なのかは、ランタイムで検査する必要があります。これはinstanceof などを使って、TypeScript のコントロールフロー解析で絞り込むことができます。

const p = new Promise<number>((resolve, reject) => {
  (null as any).foo = 1;
  return resolve(Math.random());
});
p.catch((err) => {
  if (err instanceof TypeError) {
    console.error('TypeError', err);
  } else {
    // 例外に関するミームでとりあえず全部みたいな意味
    console.error('Pokemon Catching', err);
  }
});

Promise がこのようなデザインになってる理由として、時間がかかる処理というのはネットワークのような不安定なIOを前提にしており、常に様々な例外の可能性にさらされるからです。setTimeout は幸い実行時例外を考慮しなくてもいいですが、今回はその代わり乱数が 0.5 以上、という例を出して説明しました。自分で制御可能なこのようなケースは稀です。

個人的には常に成功する Deferred 型みたいなのがあってもよいと思うんですが、現状は存在しません。

Promise Chain

Promise は Promise 同士とつなげることが出来ます。

その前に、便利なユーティリティを紹介しましょう。 new Promise((resolve, reject) => ...) を経由せずとも、 Promise.resolve(t)Promise.reject(err) で直接成功するPromise, 失敗するPromiseを作ることが出来ます。

Promise.resolve(1)
  .then((n) => n + 1)
  .then((n) => n + 1)
  .then((n) => n + 1)
  .then(console.log)

これは 4 になります。初期値 n に3回+1 してるからですね。内部的には 返り値がPromise.resolve(t) でラップされてる、と考えるとよいです。

TypeScript の型をみると、.then も Promise を返します。このときの返り値は、そのコールバック関数の返り値になります。

    then<TResult1 = T, TResult2 = never>(
      onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null
    ): Promise<TResult1 | TResult2>;

(ちなみにこの資料では一貫して then のコールバックの第2引数の onrejected を無視します。自分がほぼ使わないのと、同じ promise の .catch で同等の表現になるからです)

これを理解して暗黙の推論された型引数を自分で書くと、Promise チェーンが理解しやすいでしょう

Promise.resolve<number>(3)
  .then<string>((n) => `x`.repeat(n)) // xxx
  .then<number>((str) => str.length) // xxx.length
  .then<void>(console.log) // console.log の返り値は void

実際、これは意味がないコードですが、このコードの理解が async await の理解に繋がります。というのは、async await はこれを簡易に書くための糖衣構文に過ぎないからです。

では async await の解説…となる前に、先に非同期例外について知る必要があります。

今の例を並び替えて、わざと例外を起こします。

Promise.resolve<number>(3)
  .then<string>((n) => `x`.repeat(n))
  .then<void>(console.log)
  // @ts-expect-error
  .then<number>((str) => str.length)
  .catch(console.error);

ts の型違反が起きていますが、実際にランタイムエラーも起きます。

xxx
TypeError: Cannot read properties of undefined (reading 'length')
    at promise.ts:28:30

2つ目の then の返り値が void(undefined) なので、3つ目の then の str.length で失敗します。
このとき、Promise の連鎖は一つの Promise として解釈されており、最後の catch で捕捉されます。

ここで初手に Promise.reject してみましょう。

Promise.reject(new Error('foo'))
  .then<string>((n) => `x`.repeat(n))
  .then<void>(console.log)
  // @ts-expect-error
  .then<number>((str) => str.length)
  .catch(console.error);

Promise チェーンの正常系が全部スキップされ、一つの連鎖する Promise として catch されます。

Error: foo
    at Object.<anonymous> (...promise.ts:25:16)
    at Module._compile (node:internal/modules/cjs/loader:1101:14)
    at Module.m._compile (...node_modules/ts-node/src/index.ts:1043:23)
    at Module._extensions..js (node:internal/modules/cjs/loader:1153:10)
    at Object.require.extensions.<computed> [as .ts] (...node_modules/ts-node/src/index.ts:1046:12)
    at Module.load (node:internal/modules/cjs/loader:981:32)
    at Function.Module._load (node:internal/modules/cjs/loader:822:12)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:81:12)
    at main (...node_modules/ts-node/src/bin.ts:225:14)
    at Object.<anonymous> (...node_modules/ts-node/src/bin.ts:512:3)

then の中で Promise.reject を返すのも、Promise の失敗扱いになり、Promise チェーンが中断されます。

Promise.resolve<number>(3)
  .then(() => Promise.reject(new Error('fail')))
  .then(() => 1)
  .catch(console.error);

async await で書き換える

先に出したこの例を書き換えていきます。

Promise.resolve<number>(3)
  .then<string>((n) => `x`.repeat(n)) // xxx
  .then<number>((str) => str.length) // xxx.length
  .then<void>(console.log) // console.log の返り値は void

理想的にはこう書けます。

const n = await Promise.resolve(3);
const text = await `x`.repeat(n);
const len = await text.length;
console.log(len);

await された式(そう、これは文法的には await expr を取る式構文です) は Promise 以外でも Promise<T> に変換された上で Promise が解決されます。

とはいえ、ほとんど Promise が関わってこないので、最初のPromise 以外は剥がしてしまっていいでしょう。

const n = await Promise.resolve(3);
const text = `x`.repeat(n);
const len = text.length;
console.log(len);

が、これは Top Level Await という仕様が前提にあり、すべての実装(node, 各ブラウザ)がサポートしているとは言えない状態です。なので仕方なくこう書かれることがあります。

(async () => {
  const n = await Promise.resolve(3);
  const text = `x`.repeat(n);
  const len = text.length;
  console.log(len);
})();

これだと使ってる意味がわからないですね。もっとリアルなユースケースだとこうなります。

(async () => {
  const response = await fetch('https://api.github.com/zen');
  const text = await resposne.text();
  consle.log(text);
})();

fetch で response を取得し、response body から text として非同期にデコードします。
これが内部的なPromise Chain を直列的に書けて、直感的になりました。

ただし、見た目は直列でも await を使うごとに時間的なズレ、イベントループのステップ自体が分割されてることに注意してください。

非同期同士が絡まっている際は、これが非直感的な振る舞いを起こします。

let n = 0;
setInterval(() => {
  n++;
}, 16);

(async () => {
  console.log(n);
  const response = await fetch('https://api.github.com/zen');
  console.log(n);
  const text = await resposne.text();
  console.log(n);
  consle.log(text);
})();

setInterval の timer で更新される n と、fetch は同じスレッド内の別のタスクに属しており、fetch の処理時間が api.github.com の応答時間に依存します。このとき、 n の値がどうなるかは非決定的です。外部に依存するので制御も出来ません。これが await を使うとイベントループ上で時間的に分割される、という意味です。

async 関数の意味

何も解説なく async 関数と await 式を出しましたが、ここでその意味について考えてみましょう。

function foo(): number {
  return 1;
}

まず、この普通の foo 関数があったとします。
これを async 関数化します。

async function foo(): Promise<number> {
  return 1;
}

const p: Promise<number> = foo();
p.then(console.log) // ここでやっと 1 が手に入る

async 属性が付いた関数の返り値 T は 必ず Promise<T> になります。(一応 TS 上 PromiseLike型もありますがthen/catch/finally が実装されている必要があります)

この async 化された関数を async を使わずに表現すると、async はこのように関数を書き換えたとも言えます。

function foo(): Promise<number> {
  return Promise.resolve(1);
}

なぜか。これは async 関数の中では、「await が使える」からです。

async function bar(): Promise<number> {
  // ...実体は不明だが数値を返すとする
}

async function foo(): Promise<number> {
  const bar = await bar();
  return bar + 1;
}

これを async を使わない Promise チェーンに戻すとこうなります。

function foo(): Promise<number> {
  return Promise.resolve()
    .then(() => bar())
    .then(n => n + 1);
}

(実際には色々なPromiseに対応した書き方がありますが、例の一つとして)

async でない関数の中で await は使うことはできません。これは、非同期チェーンがイベントループ上は処理時間が分割されているからです。

非同期でない関数では、その各行が同期的に走る決まりになっています。それによってわかりやすさを得つつも、各環境のイベントループの 1tick を超過するパフォーマンス問題も引き起こします。
await はその直列実行の前提を破壊するから、最初から非同期であると宣言された関数でしか使用できない、と理解するといいでしょう。

async はアロー関数にもつくことが出来ます。つまり、最初に挙げた (async () =>{...})() は、こう解釈するといいでしょう。

const f = async () => {
  // ...
};
const p: Promise<void> = f();

async なアロー関数の即時実行です。これ自体も promise を返しているので、catch して例外処理をすることができます。

Awaited<Promise<T>>

TS の型のレベルでは、Awaited<P> が定義されており、Promise が解決された値を取得することができます。

const f = async () => {
  return 1;
};

const main = async () => {
  const p = f(); // await しないで promise をとる
  // p の型が解決された型、をここで取り出す
  const x: Awaited<typeof p> = await p;
  // @ts-expect-error
  const n: string = x;
};
main();

普通にコードを書く文には使うことは少ないですが、複雑な型引数をとるときに Promise<T> => T したいときに使うことになります。

Promise.all

Promise を理解しないと書けないものの一つに、Promise.all があります。
Promise.all は promise の配列を取り、すべてが解決したら配列でその結果を返します。

  all<T extends readonly unknown[] | []>(
    values: T
  ): Promise<{ -readonly [P in keyof T]: Awaited<T[P]> }>;

ここで、Promise を知らない人がやりがちな、間違ってる例を出します。

const results = await Promise.all([
  await p1(),
  await p2()
]);

これはコード上は正しいんですが、 Promise.all が非同期を並列に待って解決する、という側面を無視しています。なのでここは await してはいけません。このままだと、実質的にイベントループ上で分割された直列実行となっています。

正しくはこうです。

const results = await Promise.all([
  p1(),
  p2()
]);

複数の非同期の実行結果から一番早いものを返す、という Promise.race なども await してはいけない関数です。

async await と例外処理

Promise は非同期と例外を抽象したもので、その上で async await はそれを直列に見せかける文法を提供するもの、という解説をしました。

これだけでは実用には足りません。 async 関数の中で発生した非同期例外を扱う方法を知る必要があります。

async 関数 が Promise を返すということを知っていれば、 その即時実行も promise を返しており、さらに catch できる、ということに気づけると思います。

const p = (async () => {
  // ...
})();
p.catch(console.error);

しかし、async 関数の中では await を使ってる限り、await がPromise<T>T に剥がしてしまうので、 catch を差し込む場所がなくなっていることに気付くでしょう。

ここで知るべきは、「async 関数中の非同期例外(reject)は、try catch で捕捉できる」という仕様です。

async function main() {
  try {
    await Promise.reject(new Error('err'));
    console.log('unreachable!')
  } catch (err) {
    console.warn(err instanceof Error);
  }
}

main();

await 式は reject される Promise を待つと try 文の中で発生した例外扱いになります。これは then で Promise.reject() した際と同様です。

Promise.reject だけではなく、通常の例外もPromiseと同様に同様に catch します。

async function main() {
  try {
    throw new Error('err');
    console.log('unreachable!')
  } catch (err) {
    console.warn(err instanceof Error);
  }
}

main();

今の ES2021 には try/catch/finally があり、また async 内の Promise も同様に then/catch/finally に対応するようになっています。

async function main() {
  try {
    await Promise.reject(new Error('err'));
    console.log('unreachable!')
  } catch (err) {
    console.warn(err instanceof Error);
  } finally {
    console.log("always")
  }
}
main();

これを学ぶのに厄介なのは、try catch の構文はそのまま、非同期ハンドリングする、という意味が増えている点です。ES2015 以降は予約語をふやさないために、このような既存構文の意味を再解釈して新しい意味を付与することがよくあります。

余談: do expressions にみる構文の借用

まだ標準化はされていないですが、 do キーワードを借用した do expressions という Proposal があります。

tc39/proposal-do-expressions: Proposal for do expressions

let x = do {
  let tmp = f();
  tmp * tmp + 1
};

これは即時評価されるブロックで最後に評価されるセミコロンレスな式が return される、という提案です。 Rust みたいですね。

これが標準化されるかは不明ですが(Stage1だし文法的に攻めすぎてるのでもっとマイルドな形になる気がします)、既に普通のプログラミングが成立するのに必要な一式の文法を持つ言語なので、予約語が足りておらずこういう形に拡張されることがあります。

実践的な話: いつ catch すべきか

Promise 覚えたての頃は、とにかくすべてを try catch する、というコードを書いてしまいがちです。自分もそうでした。

しかし、全部が catch される必要があるという前提は、本質的に非同期のデバッグの悪さを生みます。catch しての再 throw すると、コールスタックが失われて、本来のエラー発生のスタックが見えなくなってしまうからです。

自分が async/await と Promise の try catch を書く場所は、主にこの2パターンです。

  • fetch 等の失敗時にエラーを握りつぶさないといけない場所
  • Promise Chain の入り口
    • コード全体のエントリポイント
    • window.addEventListner や EventEmitter のような、非同期チェインではないイベントリスナーの中から async 関数を呼ぶ時

このとき、「そもそも自分で例外を throw しない」というポリシーもセットにしています。例外的な振る舞いを書いたとして、それはJSの例外ではなく、エラー用の構造体を定義して、throw ではなく扱います。

というのも、JS のエラーオブジェクトは貧弱で、あんまり役に立つ情報を持ってないからです。また throw してからどこまで処理が巻き戻るか見通しが悪いと、エラーの追跡が困難になります。

アプリケーションコード中では、基本的に async 関数は成功するものとして扱って、握りつぶすもの以外は例外処理を一箇所に集約し、場合によってはそれを sentry のような metrics に投げます。結果、基本的に型のアップキャスト等で間違ったものだけが catch される、ということになります。最終的にキャッチされるものは、本質的な実装ミスであることが多いです。

自分のアプローチは特殊なものではなく、例外中立という概念で説明できます。

例外安全と例外中立 - Qiita

まとめ

  • イベントループが待機しつつぐるぐる回っており、イベントハンドラが登録されるのを待っている
  • setTimeout や Promise 等の非同期処理は、イベントループが監視してるハンドラに処理を登録しているだけ
  • async/await は Promise の糖衣構文で、イベントループ上で時間的に分割された処理を、直列に見せかけている
  • async 関数内では then/catch/finally が try/catch/finally に相当する

このテキストだけでは理解できなかった場合、 JavaScript Promiseの本 を読むといいでしょう。

Discussion