【JavaScript】async/awaitで発生したエラーのキャッチについて理解を深めたい

# はじめに

`async/await`を使ったときのエラーのキャッチでこんがらがったのでまとめます。

多分前に書いた記事の続編的なやつですが、これ単体でも読めます。

https://qiita.com/nanasi-1/items/9bedc88b992ec470f808

:::note warn
私は`async/await`や`promise`に詳しくないため、記事の内容に誤りがある可能性があります。
もし間違いを見つけた場合は、コメント等で指摘してくださると助かります。
:::


## 環境

この記事に書いてあるJavaScriptコードは、以下の環境で実行しています。
この環境以外で同じ動きをするかどうかは、私にはわかりません。

* MacOS Sonoma 14.1
* Chrome 121.0.6167.160 （たぶん）

また、当然のようにtop-level awaitが出てきます。


# エラーのスロー

まずは`async`をつけた関数内でエラーをスローしてみます。
といっても、スローする方法は二つあります。

* `throw`キーワードを使った方法
* `new Promise`の中で`reject`する方法
* （`Promise.reject`もありますが、ここでは触れません）

:::note info
`reject`のことをエラーのスローとは言わない気もしますが、この記事では`throw`と合わせてエラーをスローすると呼んでいます。
:::


というわけで、引数によって投げるエラーの種類が変わる関数を実装します。

```js:promiseFunc
/* 引数modeに入れる値によって結果が変わる
 - 何も入れない => 正常終了
 - 'throw'を入れる => エラーがthrowされる
 - 'reject'を入れる => new Promiseされ、エラーがrejectされる
*/
async function promiseFunc(mode) {
    switch (mode) {
        // エラーをrejectする
        case 'reject':
            return new Promise((_, reject) => {
                console.info('promiseFunc: エラーをreject');
                reject(new Error('promiseFunc: エラーをreject'));
            });

        // エラーをthrowする
        case 'throw':
            console.info('promiseFunc: エラーをスロー');
            throw new Error('promiseFunc: エラーをスロー');

        // デフォルトだとエラーは出さない
        default:
            console.info('promiseFunc: 正常終了');
            return '正常'
    };
}
```

## 実行してみる

というわけで、上で定義した`promiseFunc`関数を、まずは普通に実行してみたいと思います。

:::note info
**表記について**
この先で関数を実行しているところでは、以下のような表記を使っています。
- `<`がついているものは返り値
- `[i]`がついているものは`console.info`で出力されたログ
- `[error]`がついているものはエラーログ（おそらく内部で`console.error`されているもの）
- `[throw]`がついてるものはスローされたエラーが出力されたもの

例えばこんな感じです。
```js:表記の例
function sample() {
    // 'ログの例'をconsole.infoで出力する
    console.info('ログの例');

    // '正常終了'を返す
    return '正常終了';
}

< undefined

> sample(); 

[i] ログの例

< '正常終了'
```
:::

### 正常終了

まずは正常終了モードから。
以下のように`promiseFunc`を実行します。

```js:正常終了で実行
> promiseFunc();

[i] promiseFunc: 正常終了

< Promise {<fulfilled>: '正常'}
```

普通に正常終了していますね。
関数内に`console.info`を書いたので、ログも出力されています。

また、`promiseFunc`を定義するときに`async`をつけているため、返り値は`Promise`オブジェクトとないっています。

では、ここで`await`をつけて実行してみたいと思います。

```js:awaitをつけて実行
> await promiseFunc()

promiseFunc: 正常終了

< '正常'
```

見ての通り、返り値が`Promise`オブジェクトではなく文字列になりました。
この辺はわかってる人も多いでしょうが、一応書いておきました。

### throw

そしたら次は`throw`モードで実行します。
`promiseFunc`の第一引数に`'throw'`を入れます。

```js:throwで実行
> promiseFunc('throw')

[i] promiseFunc: エラーをスロー

< Promise {<rejected>: Error: promiseFunc: エラーをスロー
        at promiseFunc (<anonymous>:10:35)
        at <anonymous>:1:1}
        
[error] Uncaught (in promise) Error: promiseFunc: エラーをスロー
        at promiseFunc (<anonymous>:10:35)
        at <anonymous>:1:1
```

意図した通りにエラーが発生しました。
ここで気をつけるべきことは二つあります。

* `reject`関数を使ったわけではないのに`Promise {<rejected>: 以下略`が返ってきている
* 出力されたエラーログはあくまでログであり、処理自体は止まっていない


一つ目からわかることとしては、どうやら、`async`をつけた関数内で`throw`した場合は、**`reject`された`Promise`オブジェクトが返ってくる**みたいです。
<sub>`throw`なのに`reject`って脳がバグるなーと思ったのは私だけですかね？</sub>

また、二つ目にあるように、処理全体は止まっていません。
なので、下のように`promiseFunc`の後に処理を書いても実行されます。

```js:処理自体は止まっていない
> promiseFunc('throw')
console.info('実行確認') // これは実行される

[i] promiseFunc: エラーをスロー

[i] 実行確認 // 実行されている

< undefined

// promiseFunc内でエラーは出ている
[error] Uncaught (in promise) Error: promiseFunc: エラーをスロー
    at promiseFunc (<anonymous>:10:35)
    at <anonymous>:1:1
```

* * *

さて、では`await`をつけたらどうなるでしょう。

```js
> await promiseFunc('throw')

[i] promiseFunc: エラーをスロー

[throw] Uncaught Error: promiseFunc: エラーをスロー
    at promiseFunc (<anonymous>:10:35)
    at <anonymous>:1:7
```

先ほどと違うのは、ここではエラーがスローされているため、**処理全体が止まっている**ということです。

なので`promiseFunc`の後に処理を書いても実行されません。
```js:処理が止まっている
> await promiseFunc('throw') // ここで処理が止まる
console.info('実行確認')// 実行されない

[i] promiseFunc: エラーをスロー

[throw] Uncaught Error: promiseFunc: エラーをスロー
    at promiseFunc (<anonymous>:10:35)
    at <anonymous>:1:7

// 何も出力されていない
```

また、ログの中身から`promise`という文字が消え失せました。


### reject

というわけで`reject`モードでも実行していきます。
`throw`のときとどう違うのかが気になるところです。

```js:rejectで実行
> promiseFunc('reject')

[i] promiseFunc: エラーをreject

< Promise {<rejected>: Error: promiseFunc: エラーをreject
    at <anonymous>:7:24
    at new Promise (<anonymous>)
    at prom…}
    
[error] Uncaught (in promise) Error: promiseFunc: エラーをreject
    at <anonymous>:7:24
    at new Promise (<anonymous>)
    at promiseFunc (<anonymous>:5:20)
    at <anonymous>:1:1
```

...あれ？
`throw`で実行した時とほぼ**同じ**ですね。

<details><summary>比較用: throwで実行した時のログ</summary>

```js
> promiseFunc('throw')

[i] promiseFunc: エラーをスロー

< Promise {<rejected>: Error: promiseFunc: エラーをスロー
        at promiseFunc (<anonymous>:10:35)
        at <anonymous>:1:1}
        
[error] Uncaught (in promise) Error: promiseFunc: エラーをスロー
        at promiseFunc (<anonymous>:10:35)
        at <anonymous>:1:1
```

</details>

まず、普通に`Promise {<rejected>: 以下略}`が返ってきました。
どうやら、`async`をつけた関数内でも`new Promise`は使えるし、**`async`がない状態と同じように`reject`できる**みたいです。

:::note info
* `promiseFunc`の後に書いたコードが実行できるかどうか
* `await`をつけた状態での実行結果

については、`throw`モードと変わらなかったため省略します。
:::


# try-catchで捕捉する

では次に、発生したエラーを`try-catch`で捕捉してみます。

まず、これがベースとなるコードです。

```js:実験に使うコード
try {
    // awaitをつけたりとったりする
    // 引数は'reject'にしたり空にしたりする
    await promiseFunc('throw');
} catch (e) {
    console.info('try-catch: エラーをキャッチ', e);
}
```

見ての通り、`promiseFunc`を`try-catch`構文で囲っただけのシンプルなものです。

コメントにも書いてありますが、ここでは`await`の有無や`throw`と`reject`での違いを調べていきます。


## awaitなしの場合

まずは`await`をつけなかった場合を見ます。

```js:awaitなしのthrowモード
> // まずはthrowで実行
try {
    promiseFunc('throw');
} catch (e) {
    console.info('try-catch: エラーをキャッチ', e);
}

[i] promiseFunc: エラーをスロー

< Promise {<rejected>: Error: promiseFunc: エラーをスロー
    at promiseFunc (<anonymous>:13:19)
    at <anonymous>:2:5}
    
[error] Uncaught (in promise) Error: promiseFunc: エラーをスロー
    at promiseFunc (<anonymous>:13:19)
    at <anonymous>:2:5

// エラーはキャッチできていない
```

```js:awaitなしのrejectで実行
> // 次はrejectで実行してみる
try {
    promiseFunc('reject');
} catch (e) {
    console.info('try-catch: エラーをキャッチ', e);
}

[i] promiseFunc: エラーをreject

< Promise {<rejected>: Error: promiseFunc: エラーをreject
    at <anonymous>:7:24
    at new Promise (<anonymous>)
    at prom…}

[error] Uncaught (in promise) Error: promiseFunc: エラーをreject
    at <anonymous>:7:24
    at new Promise (<anonymous>)
    at promiseFunc (<anonymous>:5:20)
    at <anonymous>:2:5

// エラーはキャッチできていない
```

結果はこんな感じです。

* 関数内部で`throw`した場合と`reject`した場合では、実行結果は**変わらない**
* `try-catch`ではエラーは**捕捉できない**
* というか`try-catch`なしのときと結果変わってない

どうやら`await`なしの場合、`promise`内部で発生したエラーは`try-catch`では捕捉できないようです。
ただ、そのエラーによって処理が止まることはないみたいです。

```js:処理は止まらない
> 
try {
    promiseFunc('throw'); // rejectでも試したけど結果は変わらなかった
    console.info('tryの中は実行される');
} catch (e) {
    console.info('try-catch: エラーをキャッチ', e);
}
console.info('try-catchの外も実行される');

[i] promiseFunc: エラーをreject

[i] tryの中は実行される

[i] try-catchの外も実行される

// 略
```


## awaitありの場合

今度は`await`をつけて実行してみます。

```js:awaitありのthrow
>
try {
    await promiseFunc('throw');
} catch (e) {
    console.info('try-catch: エラーをキャッチ', e);
}

[i] promiseFunc: エラーをスロー

[i] try-catch: エラーをキャッチ Error: promiseFunc: エラーをスロー
    at promiseFunc (<anonymous>:10:19)
    at <anonymous>:4:11

< undefined
```

```js:awaitありのreject
>
try {
    await promiseFunc('reject');
} catch (e) {
    console.info('try-catch: エラーをキャッチ', e);
}

[i] promiseFunc: エラーをスロー

[i] try-catch: エラーをキャッチ Error: promiseFunc: エラーをreject
    at <anonymous>:6:24
    at new Promise (<anonymous>)
    at promiseFunc (<anonymous>:4:20)
    at <anonymous>:4:11

< undefined
```

結果はこんな感じです。

* 相変わらず`throw`と`reject`では挙動は変わっていない
* `await`をつけるとエラーを**キャッチできる**ようになる

また、`await`なしの時とは違いしっかりエラーが出ているので、後続の処理が止まるようになります。

```js:try内の処理は止まる
>
try {
    await promiseFunc('throw');
    console.info('try内は実行れない');
} catch (e) {
    console.info('try-catch: エラーをキャッチ', e);
}
console.info('キャッチできてるので実行される');

[i] promiseFunc: エラーをスロー

[i] try-catch: エラーをキャッチ Error: promiseFunc: エラーをスロー
    at promiseFunc (<anonymous>:10:19)
    at <anonymous>:2:11
    
[i] キャッチできてるので実行される
```


## まとめ

ここまでの結果をまとめるとこんな感じです。

* `throw`と`reject`の挙動はほぼ変わらない
* **`await`なし**で実行した場合、`try-catch`でエラーは捕捉**できない**
* **`await`あり**で実行した場合、`try-catch`でエラーは捕捉**できる**

`async`をつけた関数が`try-catch`の中にある場合、`await`をつけ忘れたら悲惨なことになりそう...


# promise.catchで捕捉する

`Promise`オブジェクトには`catch`メソッドという、`promise`内でエラーが発生した場合に実行される関数を設定するものがあります。
これを`await`や`try-catch`と併用したらどうなるのか実験していきます。

`catch`メソッドの解説はこちら↓

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Promise/catch

:::note info
`throw`と`reject`では挙動が変わらなかったため、この章からは`reject`での実行結果は省きます。
:::


## まずは普通に使ってみる

こんな感じで使います。

```js:catchを使ってみる
>
promiseFunc('throw').catch(e => {
    console.info('promise.catch: エラーをキャッチ', e);
});

[i] promiseFunc: エラーをスロー

// エラーが捕捉されている
[i] promise.catch: エラーをキャッチ Error: promiseFunc: エラーをスロー
    at promiseFunc (<anonymous>:10:19)
    at <anonymous>:1:1
    
< Promise {<fulfilled>: undefined}
```

見ての通り`promiseFunc`の内部でエラーがスローされているため、`catch`メソッドに書いた関数が実行されています。

そして、本来返り値は`rejected`になるはずですが、`catch`でエラーが捕捉されたため`fulfilled`になっています。

これが`catch`メソッドの基本的な使い方（のはず）です。


## catchの中でthrowしてみる

というわけで、`promise`内部で発生したエラーを`catch`メソッドでキャッチし、その中で再度`throw`するというのをやってみます。
要するにこういうことです。

```js:catchの中でthrow
promiseFunc('throw').catch(e => {
    console.info('promise.catch: エラーをキャッチ', e);
    throw new Error('promise.catch: エラーをキャッチ');
});

[i] promiseFunc: エラーをスロー

[i] promise.catch: エラーをキャッチ Error: promiseFunc: エラーをスロー
    at promiseFunc (<anonymous>:10:19)
    at <anonymous>:1:1
    
< Promise {<rejected>: Error: promise.catch: エラーをスロー
    at <anonymous>:3:11}
    
[error] Uncaught (in promise) Error: promise.catch: エラーをスロー
    at <anonymous>:3:11
```

処理フローはこんな感じです。

1. `promiseFunc`がエラーをスローする
1. `promise.catch`がエラーを捕捉する
1. `catch`内部でエラーがスローされる => `rejected`は再度`rejected`になる
1. `Promise{<rejected>: 略}`が返ってくる
1. `catch`で発生したエラーのログが出る

見ての通り、返ってきた`Promise`オブジェクトのステータスが`rejected`になりました。
ちなみに、さっきの結果だと`fulfilled`でした。

また、`promise`外部ではエラーは出ていないので、`promiseFunc`以降に書いたコードは普通に実行されます。


### rejectしてみる

さて、`throw`したときの挙動が上の通りですが、では`reject`なら...?
というのが気になったのでやってみます。

```js:throwの代わりにrejectしてみる
promiseFunc('throw').catch(e => {
    console.info('promise.catch: エラーをキャッチ', e);
    // rejectする
    return new Promise((_, reject) => {
        const errObj = new Error(`reject at catch: エラーをreject ${e.message}`);
        console.info(errObj.message);
        reject(errObj)
    });
});
    
[i] promiseFunc: エラーをスロー

[i] promise.catch: エラーをキャッチ Error: promiseFunc: エラーをスロー
    at promiseFunc (<anonymous>:10:19)
    at <anonymous>:1:1
    
[i] reject at catch: エラーをreject promiseFunc: エラーをスロー

< Promise {<rejected>: Error: reject at catch: エラーをreject promiseFunc: エラーをスロー
    at <anonymous>:5:28
    at new Promise …}
    
[error] Uncaught (in promise) Error: reject at catch: エラーをreject promiseFunc: エラーをスロー
    at <anonymous>:5:28
    at new Promise (<anonymous>)
    at <anonymous>:4:16
```

やたら長いですが、やってることは単純です。

1. `promiseFunc`が実行される
1. `catch`でエラーが捕捉される
1. 新しく`Promise`オブジェクトが作られ(`return new Promise`)、`reject`される
1. `Promise{<rejected>: 略}`が返ってくる
1. `catch`で発生したエラーのログが出る

要するに`throw`のときと結果変わってません。
これもう`throw`だけでいいのでは...?


## awaitをつけてみる

まずは特に工夫もなくつけてみます。

```js:awaitをつけて実行
>
await promiseFunc('throw').catch(e => {
    console.info('promise.catch: エラーをキャッチ', e);
});

[i] promiseFunc: エラーをスロー

[i] promise.catch: エラーをキャッチ Error: promiseFunc: エラーをスロー
    at promiseFunc (<anonymous>:10:19)
    at <anonymous>:1:7
    
< undefined
```

見ての通り、エラーは無事に捕捉されているみたいです。
また、返り値が`undefined`となり、`promise`じゃなくなりました。
それ以外に特に違いはなさそうです。

<sub>でもなんか直感的にわかりづらいんだよなぁ...って思うのは私だけですかね？</sub>


### 実行順を変えてみる

ただ、今のコードの実行順はこうです。

```js:awaitは最後
await ( promiseFunc('throw').catch(e => /* 略 */) );
```

これが`promise`とそうじゃないのが混在していて直感的にわかりづらい気がしたので（？）、上のコードをこんなふうに実行してみます。

```js:先にawaitが処理されるようにする
>
(await promiseFunc('throw')).catch(e => {
    console.info('promise.catch: エラーをキャッチ', e);
});

[i] promiseFunc: エラーをスロー

[throw] Uncaught Error: promiseFunc: エラーをスロー
    at promiseFunc (<anonymous>:10:19)
    at <anonymous>:1:8
```

...動いた...だと...？
てっきり`undefined`のプロパティは読めないよって言われると思ってたのに...

まず、私が想定していた処理フローはこんな感じです。

1. `promiseFunc`が実行されてエラーがスローされる
1. `await`がつくので、返り値が`undefined`になる
1. `undefined.catch`は当然エラーが出るので、ここで処理が止まる

...が、よく考えたらそんなわけなかったです。
多分正しい処理フローはこんな感じです。

1. `promiseFunc`が実行されてエラーがスローされる
1. `await`がつくので、~~返り値が`undefined`になる~~ 1でスローされた**エラーが`promise`の外でもスローされる**
1. 2で発生したエラーは**キャッチされない**ので、`catch`メソッドが実行される前に処理が止まる

...要するに、`await`の位置に`()`つけると処理が止まります。


## try-catchの中に入れてみる

というわけで混ぜてみます。

:::note warn
やってみて分かりましたが、無駄にややこしくなるだけなので、`promise.catch`と`try-catch`は混ぜないほうがいいです。
混ぜるな危険。
:::


## まずは普通に入れてみる

```js:try-catchの中にpromise.catchを入れる
>
try {
    promiseFunc('throw').catch(e => {
        console.info('promise.catch: エラーをキャッチ', e);
    });
} catch (e) {
    console.info('try-catch: エラーをキャッチ', e);
}

[i] promiseFunc: エラーをスロー

[i] promise.catch: エラーをキャッチ Error: promiseFunc: エラーをスロー
    at promiseFunc (<anonymous>:10:19)
    at <anonymous>:2:5
    
< Promise {<fulfilled>: undefined}
```

これは見ての通り、`promiseFunc`で発生したエラーが`catch`メソッドで潰された結果、特にエラーログも出ず、`try-catch`も動かなかったという感じです。

`try-catch`はないも同然なので、これは結構分かりやすいですね。


## catchの中でthrowしてみる

```js:promise.catchでthrowする
>
try {
    promiseFunc('throw').catch(e => {
        console.info('promise.catch: エラーをキャッチ', e);
        throw new Error('promise.catch: エラーをキャッチ');
    });
} catch (e) {
    console.info('try-catch: エラーをキャッチ', e);
}

[i] promiseFunc: エラーをスロー

[i] promise.catch: エラーをキャッチ Error: promiseFunc: エラーをスロー
    at promiseFunc (<anonymous>:10:19)
    at <anonymous>:2:5
    
< Promise {<rejected>: Error: promise.catch: エラーをキャッチ
    at <anonymous>:4:15}
    
[error] Uncaught (in promise) Error: promise.catch: エラーをキャッチ
    at <anonymous>:4:15
```

ここまで来ると挙動がわかってくるかもしれません。

* `promise.catch`でのエラーの捕捉はされている
* `promise`でエラーが完結しているためか、`try-catch`は動いてない

`try-catch`をつけなかったときの出力とほぼ同じなような...


## 上の状態でawaitをつけてみる

`catch`内部で`throw`する状態のまま、`await`をつけて関数を呼び出してみたいと思います。

```js:awaitをつける
>
try {
    await promiseFunc('throw').catch(e => {
        console.info('promise.catch: エラーをキャッチ', e);
        throw new Error('promise.catch: エラーをキャッチ');
    });
} catch (e) {
    console.info('try-catch: エラーをキャッチ', e);
}

[i] promiseFunc: エラーをスロー

[i] promise.catch: エラーをキャッチ Error: promiseFunc: エラーをスロー
    at promiseFunc (<anonymous>:10:19)
    at <anonymous>:2:11
    
[i] try-catch: エラーをキャッチ Error: promise.catch: エラーをキャッチ
    at <anonymous>:4:15
    at async <anonymous>:2:5
    
< undefined
```

見ての通り、`promise.catch`も`try-catch`も動いているみたいです。
そしてその結果、`try-catch`外にはエラーが漏れなかった模様です。

ただ、`try-catch`するなら`promise.catch`の部分はなくてもいい気がしますが...

```js:これでいい気がする
try {
    await promiseFunc('throw');
    // catchはあってもなくても挙動変わらん
} catch (e) {
    console.info('try-catch: エラーをキャッチ', e);
}
```


# まとめ

ここまでの結果をまとめるとこんな感じです。

* `async`がついている場合、`throw`と`reject`の挙動はほぼ同じ
    * それなら短く書ける`throw`の方がいいかも？
* `promise`オブジェクト内で処理が完結している（＝`await`がない）場合、`try-catch`ではエラーは捕捉できない
    * `await`付け忘れると悲惨なことになるかも？
* `promise.catch`内部でさらに`throw`しても、`Promise`オブジェクトのステータスが`rejected`のままになるだけでそんなに意味ない
* `try-catch`と`promise.catch`、`new Promise`からの`reject`と`throw`などは、同じプロジェクト内に混在しないように気をつけたい