はじめに
async/await
を使ったときのエラーのキャッチでこんがらがったのでまとめます。
多分前に書いた記事の続編的なやつですが、これ単体でも読めます。
私はasync/await
やpromise
に詳しくないため、記事の内容に誤りがある可能性があります。
もし間違いを見つけた場合は、コメント等で指摘してくださると助かります。
環境
この記事に書いてあるJavaScriptコードは、以下の環境で実行しています。
この環境以外で同じ動きをするかどうかは、私にはわかりません。
- MacOS Sonoma 14.1
- Chrome 121.0.6167.160 (たぶん)
また、当然のようにtop-level awaitが出てきます。
エラーのスロー
まずはasync
をつけた関数内でエラーをスローしてみます。
といっても、スローする方法は二つあります。
-
throw
キーワードを使った方法 -
new Promise
の中でreject
する方法 - (
Promise.reject
もありますが、ここでは触れません)
reject
のことをエラーのスローとは言わない気もしますが、この記事ではthrow
と合わせてエラーをスローすると呼んでいます。
というわけで、引数によって投げるエラーの種類が変わる関数を実装します。
/* 引数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 promiseFunc()
promiseFunc: 正常終了
< '正常'
見ての通り、返り値がPromise
オブジェクトではなく文字列になりました。
この辺はわかってる人も多いでしょうが、一応書いておきました。
throw
そしたら次はthrow
モードで実行します。
promiseFunc
の第一引数に'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
のときとどう違うのかが気になるところです。
> 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);
}
見ての通り、promiseFunc
をtry-catch
構文で囲っただけのシンプルなものです。
コメントにも書いてありますが、ここではawait
の有無やthrow
とreject
での違いを調べていきます。
awaitなしの場合
まずはawait
をつけなかった場合を見ます。
> // まずは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
// エラーはキャッチできていない
> // 次は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
をつけて実行してみます。
>
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
>
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
なしの時とは違いしっかりエラーが出ているので、後続の処理が止まるようになります。
>
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
メソッドの解説はこちら↓
throw
とreject
では挙動が変わらなかったため、この章からはreject
での実行結果は省きます。
まずは普通に使ってみる
こんな感じで使います。
>
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
するというのをやってみます。
要するにこういうことです。
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
処理フローはこんな感じです。
-
promiseFunc
がエラーをスローする -
promise.catch
がエラーを捕捉する -
catch
内部でエラーがスローされる =>rejected
は再度rejected
になる -
Promise{<rejected>: 略}
が返ってくる -
catch
で発生したエラーのログが出る
見ての通り、返ってきたPromise
オブジェクトのステータスがrejected
になりました。
ちなみに、さっきの結果だとfulfilled
でした。
また、promise
外部ではエラーは出ていないので、promiseFunc
以降に書いたコードは普通に実行されます。
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
やたら長いですが、やってることは単純です。
-
promiseFunc
が実行される -
catch
でエラーが捕捉される - 新しく
Promise
オブジェクトが作られ(return new Promise
)、reject
される -
Promise{<rejected>: 略}
が返ってくる -
catch
で発生したエラーのログが出る
要するにthrow
のときと結果変わってません。
これもうthrow
だけでいいのでは...?
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 ( promiseFunc('throw').catch(e => /* 略 */) );
これがpromise
とそうじゃないのが混在していて直感的にわかりづらい気がしたので(?)、上のコードをこんなふうに実行してみます。
>
(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
のプロパティは読めないよって言われると思ってたのに...
まず、私が想定していた処理フローはこんな感じです。
-
promiseFunc
が実行されてエラーがスローされる -
await
がつくので、返り値がundefined
になる -
undefined.catch
は当然エラーが出るので、ここで処理が止まる
...が、よく考えたらそんなわけなかったです。
多分正しい処理フローはこんな感じです。
-
promiseFunc
が実行されてエラーがスローされる -
await
がつくので、返り値が1でスローされたエラーがundefined
になるpromise
の外でもスローされる - 2で発生したエラーはキャッチされないので、
catch
メソッドが実行される前に処理が止まる
...要するに、await
の位置に()
つけると処理が止まります。
try-catchの中に入れてみる
というわけで混ぜてみます。
やってみて分かりましたが、無駄にややこしくなるだけなので、promise.catch
とtry-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してみる
>
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
をつけて関数を呼び出してみたいと思います。
>
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
の部分はなくてもいい気がしますが...
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
などは、同じプロジェクト内に混在しないように気をつけたい