LoginSignup
2
0

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

Posted at

はじめに

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

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

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

環境

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

  • MacOS Sonoma 14.1
  • Chrome 121.0.6167.160 (たぶん)

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

エラーのスロー

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

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

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

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

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関数を、まずは普通に実行してみたいと思います。

表記について
この先で関数を実行しているところでは、以下のような表記を使っています。

  • <がついているものは返り値
  • [i]がついているものはconsole.infoで出力されたログ
  • [error]がついているものはエラーログ(おそらく内部でconsole.errorされているもの)
  • [throw]がついてるものはスローされたエラーが出力されたもの

例えばこんな感じです。

表記の例
function sample() {
    // 'ログの例'をconsole.infoで出力する
    console.info('ログの例');

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

< undefined

> sample(); 

[i] ログの例

< '正常終了'

正常終了

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

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

[i] promiseFunc: 正常終了

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

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

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

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

awaitをつけて実行
> await promiseFunc()

promiseFunc: 正常終了

< '正常'

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

throw

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

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オブジェクトが返ってくるみたいです。
throwなのにrejectって脳がバグるなーと思ったのは私だけですかね?

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

処理自体は止まっていない
> 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をつけたらどうなるでしょう。

> await promiseFunc('throw')

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

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

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

なのでpromiseFuncの後に処理を書いても実行されません。

処理が止まっている
> await promiseFunc('throw') // ここで処理が止まる
console.info('実行確認')// 実行されない

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

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

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

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

reject

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

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で実行した時とほぼ同じですね。

比較用: 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

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

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

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

try-catchで捕捉する

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

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

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

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

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

awaitなしの場合

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

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

// エラーはキャッチできていない
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では捕捉できないようです。
ただ、そのエラーによって処理が止まることはないみたいです。

処理は止まらない
> 
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をつけて実行してみます。

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
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

結果はこんな感じです。

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

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

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] キャッチできてるので実行される

まとめ

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

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

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

promise.catchで捕捉する

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

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

throwrejectでは挙動が変わらなかったため、この章からはrejectでの実行結果は省きます。

まずは普通に使ってみる

こんな感じで使います。

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するというのをやってみます。
要するにこういうことです。

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がエラーをスローする
  2. promise.catchがエラーを捕捉する
  3. catch内部でエラーがスローされる => rejectedは再度rejectedになる
  4. Promise{<rejected>: 略}が返ってくる
  5. catchで発生したエラーのログが出る

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

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

rejectしてみる

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

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が実行される
  2. catchでエラーが捕捉される
  3. 新しくPromiseオブジェクトが作られ(return new Promise)、rejectされる
  4. Promise{<rejected>: 略}が返ってくる
  5. catchで発生したエラーのログが出る

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

awaitをつけてみる

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

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じゃなくなりました。
それ以外に特に違いはなさそうです。

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

実行順を変えてみる

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

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

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

先に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が実行されてエラーがスローされる
  2. awaitがつくので、返り値がundefinedになる
  3. undefined.catchは当然エラーが出るので、ここで処理が止まる

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

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

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

try-catchの中に入れてみる

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

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

まずは普通に入れてみる

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してみる

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をつけて関数を呼び出してみたいと思います。

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.catchtry-catchも動いているみたいです。
そしてその結果、try-catch外にはエラーが漏れなかった模様です。

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

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

まとめ

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

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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0