Edited at

await/catchの実装パターンを考えてみた

More than 1 year has passed since last update.

この記事は @akameco さんの次の記事を前提にしています。

他のケースでも嬉しい書き方はできるだろうか、試してみよう、という性質の記事です。


前提として共有しておきたい感情


  • 忌々しいletを使わずconstで書けるの最高 :thumbsup:

  • try-catchの冗長さがなくなるの最高 :thumbsup:


題材:複数のエラーオブジェクトを使ってエラーハンドリングをしたいケースに対応したい

前述した記事の本筋のうれしみは↑のふたつだったと思うのですが、エラーハンドリングのパターンとしては、大別して次の2パターンが紹介されていたように思います。


  • エラーの有無に関わらず必ず値を返す使い方

  • 個別のPromiseに対してエラーハンドリングを行う使い方


エラーの有無に関わらず必ず値を返す使い方

const name = await getName().catch(() => 'ゲスト')



個別のPromiseに対してエラーハンドリングを行う使い方

await f().catch(handleErr1)

await g().catch(handleErr2)

ここで私は考えました。「複数のawaitを使って得られたエラーを複合的に扱いたい場合はどう書けるだろう・・・」と。要は以下のようなケースです。

let name;

let error1;
try {
name = await fetchName();
} catch (err) {
error1 = err;
}

let nickName;
let error2;
try {
nickName = await fetchNickName();
} catch (err) {
error2 = err;
}

if (error1 && error2) {
// error1とerror2の組み合わせを活かした処理
return;
}

showAnyName(name || nickName);

実際にこういうケースがあるのかわかりませんが、「エラーの出方次第では処理を中断しましょう。でもnameかnickNameが取れてれば何とか動かしましょう」というポリシーのやつです。


試行1:エラーをそのまま値として返してみる

上記の処理を前述の「エラーの有無に関わらず必ず値を返す使い方」で書いてみます。

const nameOrError = await fetchName().catch(err => err);

const nickNameOrError = await fetchNickName().catch(err => err);

型ァァァァァ!!!!! ・・・失礼、取り乱しました。

型定義を書いてみると、次のようなものが返ってきます。


nameOrErrorとnickNameOrErrorの型定義

type NameOrError = string | Error;


駄目です。これは承服し難い事態になりました。文字列が返ってくるかエラーオブジェクトが返ってくるか分からない変数というのは型安全教信者の私には耐えられません。このあとの処理を書く気にもなりませんでした。


試行2:オブジェクトに包んで返してみる

せめて同じ型で扱えるようにしよう、ということでオブジェクトに包んでみたのが次の方法です。

const result1 = await fetchName()

.then(name => ({ name }))
.catch(error => ({ error }));
const result2 = await fetchNickName()
.then(nickName => ({ nickName }))
.catch(error => ({ error }));

if (result1.error && result2.error) {
// result1とresult2のエラーを活かした処理
return;
}

showAnyName(result1.name || result2.nickName);

この場合のresult1とresult2の型は次のようなものになります。


result1の型

type NameResult = {

name?: string;
error?: Error;
}


result2の型

type NickNameResult = {

nickName?: string;
error?: Error;
}

errorがにオブジェクトが入っていればエラーあり、そうでなければnameやnickNameがもらえている、という流れになりました。現実的にはこの辺が落とし所なのかなと思います。というか元々レアケースだと思うので、あまり深入りするつもりはありません。


試行3:分割代入でresult変数を消してみる

前項でやりたいことはできているのですが、もう一段階手を加えてみます。

const { name, error1 } = await fetchName()

.then(name => ({ name }))
.catch(error1 => ({ error1 }));
const { nickName, error2 } = await fetchNickName()
.then(nickName => ({ nickName }))
.catch(error2 => ({ error2 }));

if (error1 && error2) {
// error1とerror2のエラーを活かした処理
return;
}

showAnyName(name || nickName);

なんかgolangで見たことあるような気がするスタイルになりました。見た目ちょっと小奇麗に見えますが、


  • 左辺のnameやnickNameという名前が右辺のPromise内で返すオブジェクトの実装に依存する

  • errorを受け取らないという選択肢がある(握りつぶせる)

という点で、golangのそれと比べてはいけない微妙なやつだったりします。


試行4:変数名を統一したり型を一般化したり

変数の命名については、分割代入のエイリアス記法があるので一応解決できます。

const { result: name, error: error1 } = await fetchName()

.then(result => ({ result }))
.catch(error => ({ error }));
const { result: nickName, error: error2 } = await fetchNickName()
.then(result => ({ result }))
.catch(error => ({ error }));

if (error1 && error2) {
// error1とerror2のエラーを活かした処理
return;
}

showAnyName(name || nickName);

ここまでやると、TypeScriptやFlowで書くときにジェネリクスで戻り値を一般化できるという方面で嬉しみが出るかもしれません。


Promiseが返す型を一般化してみる

type Result<T> = {

result?: T;
error?: Error;
}

TypeScriptで型付けしながら書いてみると、次のような感じに書けそうです。

async function fetchNameWrapped(): Promise<Result<string>> {

return fetchName()
.then(result => ({ result }))
.catch(error => ({ error }));
}
async function fetchNickNameWrapped(): Promise<Result<string>> {
return fetchNickName()
.then(result => ({ result }))
.catch(error => ({ error }));
}
// ↑の処理を別の場所で済ませておく

// nameとnickNameには型推論が効いてstringとして扱われる
const { result: name, error: error1 } = await fetchNameWrapped();
const { result: nickName, error: error2 } = await fetchNickNameWrapped();

if (error1 && error2) {
// error1とerror2のエラーを活かした処理
return;
}

showAnyName(name || nickName);

うーん、なんかやりすぎた気がする・・・


まとめ

いろいろ試してみたメモを書いただけなのでまとまりがないですが、こんな感じになりました。途中から型中心のモチベーションになってましたね・・・

Result<T> には何かちゃんとした呼び方がありそうな気がするのですが、私の勉強不足で名付けようがなかったので、どなたか教えてくださると幸いです。

いやーたのしかった!(小並感)


おまけ

動作確認に使ってたスニペットを置いときます。Chrome61のコンソールに貼り付けながら動作確認してました。

(() => {

const fetchNameResolve = () => Promise.resolve("taro");
const fetchNameReject = () => Promise.reject(new Error("name is not found"));
const fetchNickNameResolve = () => Promise.resolve("たろちゃん");
const fetchNickNameReject = () => Promise.reject(new Error("nickName is not found"));

async function initName() {
const { result: name , error: error1 } = await fetchNameResolve()
.then(result => ({ result }))
.catch(error => ({ error }));
const { result: nickName, error: error2 } = await fetchNickNameResolve()
.then(result => ({ result }))
.catch(error => ({ error }));

if (error1 && error2) {
console.error(error1);
console.error(error2);
return;
}

console.log(name, nickName);
}

initName();
})();


アドバイス頂きました

type Result<T> = { result: T; } | { error: Error; };

async function wrapped<T>(f: () => Promise<T>): Promise<Result<T>> {
return f().then((result) => ({ result }), (error) => ({ error }));
}

よさげな高階関数をいただきました。

手元のコードに埋め込んでみたところ、次のような形になりました。

type Result<T> = { result?: T, error?: Error; };

async function wrapped<T>(f: () => Promise<T>): Promise<Result<T>> {
return f().then((result) => ({ result }), (error) => ({ error }));
}
// ↑をどこかに定義しておく

// fetchName: () => Promise<string>
// fetchNickName: () => Promise<string>

// nameとnickNameには型推論が効いてstringとして扱われる
const { result: name, error: error1 } = await wrapped(fetchName);
const { result: nickName, error: error2 } = await wrapped(fetchNickName);

if (error1 && error2) {
// error1とerror2のエラーを活かした処理
return;
}

showAnyName(name || nickName);

fetchNameとfetchNickNameが返すPromiseには確実に型パラメータ(T)が付いているので、wrapped関数の型パラメータも自動で定まるというのがミソですね。

実用性が一歩上がった感じがします。ありがたや。