実例 extends Error / TypeScript一人カレンダー
こんにちは、クレスウェア株式会社の奥野賢太郎 (@okunokentaro) です。本記事はTypeScript 一人 Advent Calendar 2022の23日目です。昨日は『実例 mapOrElse()
』を紹介しました。
TypeScriptプログラミングにおけるエラー
TypeScriptプログラミングにおいて、エラーをどのように発生させ、あるいはどのようにハンドリングするかという話題は一考に値します。
TypeScriptは優れた型システムを有しているため、その機構を最大限に生かそうとHaskellやRustといった他の言語からEither
モナドや、Maybe
モナドの概念を拝借して組み合わせたり、Result
型を定義して、Ok<T>
型とError<E>
型を扱うようにしたりと、様々な工夫をしている開発者がいます。こういった仕組みを提供するライブラリとして、TypeScript界隈ではfp-ts
が有名かと思います。
もしここでモナドって何?となっても心配しなくて大丈夫です。安心してください、筆者もわかっていません。
そして筆者は業務にfp-ts
を導入していません。ただし、fp-ts
不採用の理由は筆者の知識や嗜好によるものではなく、客観的な判断理由を挙げることもできます。
ECMAScript処理系で実行されるという事実からは逃れることができない
TypeScriptで型を扱えるようになったおかげで、throw
やtry-catch
を使わないエラーの送出、捕捉をしようと試みたり、戻り型アノテーションにエラー型を明記するように工夫したりと、プログラミングの幅自体はたしかに広がりました。少し検索すると、そういったエラーハンドリングを導入している登壇資料や技術記事がヒットすることがわかります。
しかし、TypeScriptはどこまでいってもECMAScript処理系で実行される運命です。少なくとも2022年ではそうです。たとえDenoだとしても、処理系のベースはV8でありthrow
やtry-catch
のなくなった世界ではありません。
ということは、どれだけTypeScriptの言語上で創意工夫をしたとしても「try-catch
しなくていい世界」に進めるわけではないのです。それはどこまでいっても「try-catch
が必要な世界で、していないだけ」なのです。
この話題は、ライブラリを何も導入せずにすべて自前のコードで構成する場合は気にならないかもしれませんが、複数のライブラリを組み合わせるときに顕著となります。独自のエラーハンドリング・ライブラリと、エラー用の型定義を作ったとしても、そこに追加で導入する他のライブラリがその独自のエラー機構に準拠していることはまず期待できず、そのライブラリは普通に例外をthrow
してきます。
また、開発者が複数人混ざる場合にも懸念があります。ECMAScriptやTypeScriptをよく理解している開発者だとしても、その参入先の現場の「独自のエラーハンドリング機構」には馴染みがないかもしれません。そういった場合は、まず仕組みを学ぶという段階をひとつ踏む必要がでてきます。
完璧なフォローはコストが高い
これは筆者の私見でしかないのですが、他のライブラリのエラーを全く取りこぼすことなく独自のエラーハンドリング機構に組み込み、かつ出入りする他の開発者のキャッチアップも怠らないというサポートコストは、その独自エラーハンドリングを導入する利点よりも明らかに上回っていると判断しています。
開発者のキャッチアップへのフォローはさておき「他のライブラリのエラーを全て取りこぼさず独自エラーハンドリング側に寄せる」という手配は、ECMAScriptを実行する上で悪魔の証明に近いことであり、とても困難です。
そのため筆者は、ECMAScriptが古典的に備えるthrow
とtry-catch
を使うようにしています。その構文や機構にどれだけ不満があろうとも、今のECMAScript処理系がそうなのだから、それは仕方のないことです。
catch
ブロック内での型付け
TypeScriptではcatch
ブロックにおけるエラー変数(ここではerr
とする)の型は、かなり長い間any
型として扱われていました。どこから何が投げられるかわからないという事情でany
型であることはやむを得ないです。
any
型は、TypeScript登場から現在までを追ったとき、初期〜中期は稀に登場しても仕方ないものとして扱われていた傾向がありました。流れが変わったのはTypeScript 3.0でのunknown
の誕生以降です。ここから、any
型は原則的に忌避すべきものという潮流に変わっていったように感じます。やむを得ない事情であっても、そこはany
型ではなくunknown
型を使うべきという風潮が生まれたためです。
それからも、しばらくはcatch
での変数err
はany
型のままでした。TypeScript 4.0でようやくunknown
型の変数アノテーションを付与できるようになります。
そしてさらに時は流れて、TypeScript 4.4でついにstrict
有効時のデフォルトで、変数err
はアノテーションなしにany
型からunknown
型として扱われるようになりました。古典的なtry-catch
のままではありますが、着実にTypeScript側の扱いは進化してきたのです。
catch
のerr
はError
型インスタンスとは限らない
catch
ブロックの変数err
がunknown
型として扱われるということは、それがError
型であるかどうかを毎回確認する必要が出ました。
確認にはinstanceof
演算子によるNarrowingが必要です。公式のサンプルコードを部分的に引用します。
declare function executeSomeThirdPartyCode(): void;
try {
executeSomeThirdPartyCode();
} catch (err) {
// Error! Property 'message' does not exist on type 'unknown'.
console.error(err.message);
if (err instanceof Error) {
console.error(err.message);
}
}
このようにif (err instanceof Error)
を使うことで変数err
はError
のオブジェクトだと確定し、err.message
を参照できるようになります。
err: Error
であると扱えない理由
暗黙的にここで疑問を持つ方もいるかもしれません。エラーのキャッチなんだから、err
は常にError
型としておけば楽なのでは?ということです。これには、そうできない事情があります。
ECMAScriptでは、throw
できる値としてECMAScriptプログラム上で扱えるあらゆる値であると定義しています。これは仕様書から明らかです。
The abstract operation ThrowCompletion takes argument value (an ECMAScript language value)
An ECMAScript language type corresponds to values that are directly manipulated by an ECMAScript programmer using the ECMAScript language. The ECMAScript language types are Undefined, Null, Boolean, String, Symbol, Number, BigInt, and Object.
そのためプリミティブ値も投げることができます。だからといって、業務でこうすることは推奨しません。throw new Error()
しましょう。
function throwString(): never {
throw "hello world";
}
try {
throwString();
} catch (e) {
console.log(e); // "hello world"
console.log(typeof e === "string"); // true
}
extends Error
実例 筆者の参加する案件では、近年はもっぱらextends Error
を伴った拡張エラークラスを使用するようにしています。例えば次のような実装です。
class ForbiddenError extends Error {}
class InternalServerError extends Error {}
メソッドの実装もconstructor
の実装もなく、ただextends Error
を書き加えたのみです。
筆者はクラスを業務で使用していないと述べました。これはJSONシリアライズ・デシリアライズの観点で懸念が多いためです。それでも筆者がクラスを採用するのは、唯一このError
の例のみです。
こうすることで、エラー送出側でthrow new ForbiddenError()
やthrow new InternalServerError()
と書くことができます。
このやり方には賛否両論あり、ある人は常にthrow new Error("internal server error")
として、if (err.message.includes("internal server error"))
のようにメッセージで判別すべきという方もいます。
これに正解はないと思うのですが、TypeScriptにおいては変数err
がunknown
型であるという事情が全体的な設計を左右していると感じます。もしerr.message
を使ったメッセージによるエラー分岐をしようとすると、if (err instanceof Error && err.message.includes())
と書かないと、TypeScript上では変数err
にmessage
もincludes()
も生えているとして扱えないためです。
変数err
がany
型だった時代は、たしかにいくつかの方法論を取りうると感じました。そこからunknown
型となりinstanceof
演算子の使用が必須となった今では、message.includes()
を使うよりも、さっさとError
のサブクラスを作ってしまってそれで分岐したほうがcatch
ブロック内をシンプルに扱えます。
Errorを継承しないクラスはおすすめしない
ここで、extends
抜きにclass ForbiddenError {}
とはしないのかという話題に振れておきます。
extends Error
することによってECMAScript処理系の標準的なエラーログ表示の恩恵を受けることができます。その挙動の違いは次のような2行のコードをそれぞれブラウザのコンソールで実行してみると明らかです。
try { throw new (class MyError {}) } catch(e) { console.dir(e); }
try { throw new (class MyError extends Error {}) } catch(e) { console.dir(e); }
class MyError {}
の場合は行数や文字数が表示されることがありません。そのため自作のエラークラスを扱う場合はextends Error
を指定して、サブクラス扱いにすることをお勧めします。
RGBA
』
明日は『実例 再帰型定義といよいよTypeScript 一人 Advent Calendar 2022も残すところあと2日となりました。ラスボスは最終日に残しておくとして、明日は一旦小咄としましょう。それではまた。
Discussion
面白いアドベントカレンダーをありがとうございます! 一点気になったのでコメントします。
個人的にはここに更にインスタンスの
name
プロパティに書き込むようにしています。例えば以下のような感じです。こうすることでスタックトレースの文字列にどのエラーが発生したかを残すことが出来ます[1]。参考にしていただけたら幸いです。
スタックトレースはECMAScriptに記載されておらず実装依存なのですが、基本的な処理系であるV8、SpiderMonkeyそしてJavaScriptCoreで
name
プロパティが使われることを確認しました。 ↩︎とてもわかりやすいですね。ご紹介ありがとうございます。