何番煎じかわかりませんが TypeScript でのエラーハンドリングについて考えてみたいと思います。
この記事で扱う TypeScript のバージョンは 4.3 です。
エラーを型安全に扱いたい
TypeScript を書いていれば誰もが一度はぶつかる問題ではないでしょうか。
TypeScript では catch
した例外は any
として扱われます。
これは JavaScript の仕様上どんな値でも throw
できてしまうため仕方のないことなのですが、せっかく型安全性を手に入れるために TypeScript を使っているのに any
をハンドリングしなければならないのは苦痛です。
次のように例外を throw
し得る関数 foo()
のエラーハンドリングを考えてみます。
try {
// 例外を throw する処理
foo();
} catch (e) {
// ...
}
e
は any
なので、プロパティにアクセスしようにも危険性が伴います。
そこで型アノテーションを使用して any
よりも型安全な unknown
として扱うことにします。
(ちなみに TypeScript 4.4 から導入予定の useUnknownInCatchVariables
設定を使えば型アノテーションなしで catch
した値を unknown
として扱うことができます)
try {
// 例外を throw する処理
foo();
} catch (e: unknown) {
// ...
}
これでタイプガードを使用することにより e
のプロパティに安全にアクセスすることができます。
try {
// 例外を throw する処理
foo();
} catch (e: unknown) {
if (e instanceof Error) {
console.error(e.message);
}
}
上記のように単純なエラーハンドリングの場合は良いのですが、実際には foo()
が throw
する例外の種類に応じて適切な処理を行わなければならないことが多いでしょう。
TypeScript には Java の throws
のように関数が throw
する例外を宣言する方法がないので、どのような例外が throw
され得るかを知るにはコードを読むしかありません。
function foo() {
// ErrorX, ErrorY を throw することを
// ここから読み取らなければならない。
}
try {
// 例外を throw する処理
foo();
} catch (e: unknown) {
if (e instanceof ErrorX) {
// ErrorX のハンドリング
// ...
} else if (e instanceof ErrorY) {
// ErrorY のハンドリング
// ...
} else {
// ...
}
}
foo()
が複雑な関数の場合、throw
され得る例外の型を読み取ることは難しくなります。
同様のことが Promise にも言えます。
catch()
の引数の型は Promise のジェネリクスの型引数で指定することはできず、常に any
として扱われます(これは Promise が throw
によっても reject
できることに起因しています)。
function fooAsync(): Promise<void> {
// ErrorX, ErrorY で reject することを
// ここから読み取らなければならない。
}
fooAsync().catch((e: unknown) => {
if (e instanceof ErrorX) {
// ErrorX のハンドリング
// ...
} else if (e instanceof ErrorY) {
// ErrorY のハンドリング
// ...
} else {
// ...
}
});
どうにかして関数が throw
する例外の型や Promise が reject
する値の型を TypeScript の型システムの中で表現したいところです。
型安全なエラーハンドリングのためのアプローチ
ではどうすれば良いのかを調べてみると TypeScript のエラーハンドリングに関する素晴らしい記事がいくつか見つかりました。
詳しくはこれらの記事を実際に読んでいただきたいのですが、総合すると Result
や Either
のような正常系と異常系のどちらかを取るような型を導入し、例外を throw
する代わりに return
する方法が良さそうです。
また、このようなアプローチを実現するためのライブラリがいくつかあることがわかりました。
これらのライブラリを採用しても良いのですが、前述の「TypeScriptの異常系表現のいい感じの落とし所」にも記述があるようにライブラリへの依存度が高くなりすぎてしまうという問題があります。仮に採用したライブラリがメンテナンスされなくなってしまった場合、プロジェクトへの影響は深刻です。
また、fp-ts や lifts は TypeScript で関数型プログラミングを実現するためのライブラリなので、「エラーを型安全に扱いたい」という今回の目的に対して些か過剰です。
Result 型を作ってみる
というわけで今回はこれらの記事やライブラリを参考に自分なりに Result
型を作ってみたいと思います。
Result
型は次のように定義してみました。
type Result<T, E extends Error> = Success<T> | Failure<E>;
class Success<T> {
readonly value: T;
constructor(value: T) {
this.value = value;
}
isSuccess(): this is Success<T> {
return true;
}
isFailure(): this is Failure<Error> {
return false;
}
}
class Failure<E extends Error> {
readonly error: E;
constructor(error: E) {
this.error = error;
}
isSuccess(): this is Success<unknown> {
return false;
}
isFailure(): this is Failure<E> {
return true;
}
}
Result
は正常系を意味するクラスの Success
と異常系を意味するクラスの Failure
のいずれかの値を取る Union
として定義しています。
この Result
型によりエラーハンドリングを次のように書くことができます。
function foo(): Result<string, ErrorX | ErrorY> {
// 正常系
// Success を返す。
return new Success("success");
// 異常系
// throw は使わずに Failure を返す。
return new Failure(new ErrorX("x"));
}
(() => {
const result = foo();
if (result.isFailure()) {
// 異常系の処理
// 型推論により err が ErrorX または ErrorY であることがわかる。
const err = result.error;
if (err instanceof ErrorX) {
// ErrorX のハンドリング
// ...
return;
}
// ErrorY のハンドリング
// ...
return;
}
// 正常系の処理
console.log(result.value);
})();
Result
を使うことでエラーを型安全に扱えるだけでなく、呼び出し元でのエラーハンドリングを強制することができています。
Promise (async/await) と Result
Promise の場合も Result
型を使って型安全にエラーを扱うことができます。
扱いやすさのため、次のような型を定義しました。
type PromiseResult<T, E extends Error> = Promise<Result<T, E>>;
次のように書くことができます。
function fooAsync(): PromiseResult<string, ErrorX | ErrorY> {
return new Promise<Result<string, ErrorX | ErrorY>>((resolve) => {
// 正常系
// Success で resolve する。
resolve(new Success("success"));
// 異常系
// throw や reject は使わずに Failure で resolve する。
resolve(new Failure(new ErrorX("x")));
});
}
(async () => {
const result = await fooAsync();
if (result.isFailure()) {
// 異常系の処理
// 型推論により err が ErrorX または ErrorY であることがわかる。
const err = result.error;
if (err instanceof ErrorX) {
// ErrorX のハンドリング
// ...
return;
}
// ErrorY のハンドリング
// ...
return;
}
// 正常系の処理
console.log(result.value);
})();
Result の Tips
例外を throw する関数を扱う
標準ライブラリや外部ライブラリの関数を呼び出す場合は、try...catch
で適切に処理して Result
として扱うようにします。
次のような関数があると便利です。
function tryCatch<T, E extends Error>(
func: () => T,
// 発生する例外は any なので適切な型に変換するための
// 関数を与える。
onCatch: (e: unknown) => E
): Result<T, E> {
try {
const value = func();
return new Success<T>(value);
} catch (err) {
return new Failure<E>(onCatch(err));
}
}
標準ライブラリや外部ライブラリの Promise も同様に PromiseResult
として扱うようにします。
async function tryCatchAsync<T, E extends Error>(
func: () => Promise<T>,
// 発生する例外は any なので適切な型に変換するための
// 関数を与える。
onCatch: (e: unknown) => E
): PromiseResult<T, E> {
try {
const value = await func();
return new Success<T>(value);
} catch (err) {
return new Failure<E>(onCatch(err));
}
}
Result から例外を取り出す
ライブラリを作る場合、内部処理でのエラーハンドリングは Result
で行うにしても外部に提供する API については Error
を throw
する仕様である方が好ましいでしょう。
次のように Result
が内包する値を取り出すための関数があると重宝します。
function unwrap<T, E extends Error>(result: Result<T, E>): T {
if (result.isFailure()) {
throw result.error;
}
return result.value;
}
Promise.all() を使う
Promise.all()
を使いたい場合は注意が必要です。
PromiseResult
を使う場合、 Promise は正常系であろうと異常系であろうと常に fulfilled
となります。
つまり PromiseResult
を使っている Promise を何も考えずに Promise.all()
で使ってしまうと、Promise.all()
のフェイルファストの挙動を実現できません(Promise.allSettled()
のような挙動になります)。
PromiseResult
でフェイルファストを実現するには、全ての Promise が Success<T>
を返したら Success<T[]>
、1 つでも Failure<E>
を返したら Failure<E>
を返すような関数が必要です。
この問題を解決するため、最初は次のような関数を定義してみました。
前述の unwrap()
を使って一時的に Error
を throw
するようにしてしまえば Promise.all()
で処理できるだろうというアイディアです。
function promiseAll<T, E extends Error>(
// 本来の Promise.all() は PromiseLike や非 Promise な値も
// 受け付けますが簡単のために PromiseResult の配列に限定しています。
values: readonly PromiseResult<T, E>[],
onCatch: (e: unknown) => E
): PromiseResult<T[], E> {
return tryCatchAsync(() => {
return Promise.all(values.map(async (v) => unwrap(await v)));
}, onCatch);
}
ただ実際に使ってみるとこの関数は失敗であることがわかりました。
unwrap()
することで Failure
が持っていたエラーの型情報が失われてしまうため、catch
した値を元の型に戻すための onCatch
が必要となってしまいます。
元々正しい型を持っていたのに再度変換するための関数を書くのは冗長ですし、複数種類のエラーを扱うケースでは onCatch
が複雑になって書くのが面倒です。
そこで次のような形に修正しました。
function promiseAll<T, E extends Error>(
// 本来の Promise.all() は PromiseLike や非 Promise な値も
// 受け付けますが簡単のために PromiseResult の配列に限定しています。
values: readonly PromiseResult<T, E>[]
): PromiseResult<T[], E> {
return new Promise<Result<T[], E>>((resolve, reject) => {
const results: T[] = [];
for (const v of values) {
v.then((result) => {
if (result.isFailure()) {
resolve(result);
return;
}
results.push(result.value);
if (results.length === values.length) {
resolve(new Success(results));
}
}).catch(reject);
}
});
}
Promise.all()
を利用することを諦めて、ほぼ同等の処理を自前で書いています。
与えられた Promise のいずれかが Failure
で解決された場合は直ちにその Failure
で resolve
することによって Failure
が持つエラーの型情報を失うことなくフェイルファストの挙動を再現しています。
型安全なエラーの弊害と用法
Result
のような型を導入してエラーを型安全に扱うことにはメリットもありますが、注意して扱わないとかえってデメリットをもたらすこともあります。
このデメリットとは Java の検査例外についてよく言われるものとほぼ同様のものです。
雑に説明してしまうと次のようなものがあります。
- バックエンドの変更などにより関数が返すエラーの型を変更するとコードの互換性が損なわれてしまう
- いわゆる「オープン・クローズドの原則」に違反する
- インターフェースや抽象クラスで指定したエラーの型が具象クラスで発生するエラーの型を制限してしまう
- 関数が返すエラーの型が増えすぎてハンドリングしきれなくなる
これらの問題は次のように適切な設計をすることである程度回避することができます。
- 下位レイヤーのエラーをそのまま上位レイヤーに持ち出さない
- レイヤーに合わせて適切にエラーを抽象化する
- 呼び出し側でハンドリングできない (すべきでない) エラーを返さない
- ハンドリングできないエラーは
Result
にせずthrow
するなど
- ハンドリングできないエラーは
つまり型安全にエラーを扱おうとすると必然的にエラー設計のコストが大きくなります。
エラーを型安全にするメリットがそのコストに見合うかどうかについてはよく考える必要がありそうです。