🎄

実例 extends Error / TypeScript一人カレンダー

2022/12/24に公開約8,000字2件のコメント

こんにちは、クレスウェア株式会社の奥野賢太郎 (@okunokentaro) です。本記事はTypeScript 一人 Advent Calendar 2022の23日目です。昨日は『実例 mapOrElse()』を紹介しました。

TypeScriptプログラミングにおけるエラー

TypeScriptプログラミングにおいて、エラーをどのように発生させ、あるいはどのようにハンドリングするかという話題は一考に値します。

TypeScriptは優れた型システムを有しているため、その機構を最大限に生かそうとHaskellRustといった他の言語からEitherモナドや、Maybeモナドの概念を拝借して組み合わせたり、Result型を定義して、Ok<T>型とError<E>型を扱うようにしたりと、様々な工夫をしている開発者がいます。こういった仕組みを提供するライブラリとして、TypeScript界隈ではfp-tsが有名かと思います。

もしここでモナドって何?となっても心配しなくて大丈夫です。安心してください、筆者もわかっていません。

そして筆者は業務にfp-tsを導入していません。ただし、fp-ts不採用の理由は筆者の知識や嗜好によるものではなく、客観的な判断理由を挙げることもできます。

ECMAScript処理系で実行されるという事実からは逃れることができない

TypeScriptで型を扱えるようになったおかげで、throwtry-catchを使わないエラーの送出、捕捉をしようと試みたり、戻り型アノテーションにエラー型を明記するように工夫したりと、プログラミングの幅自体はたしかに広がりました。少し検索すると、そういったエラーハンドリングを導入している登壇資料や技術記事がヒットすることがわかります。

しかし、TypeScriptはどこまでいってもECMAScript処理系で実行される運命です。少なくとも2022年ではそうです。たとえDenoだとしても、処理系のベースはV8でありthrowtry-catchのなくなった世界ではありません。

ということは、どれだけTypeScriptの言語上で創意工夫をしたとしても「try-catchしなくていい世界」に進めるわけではないのです。それはどこまでいっても「try-catchが必要な世界で、していないだけ」なのです。

この話題は、ライブラリを何も導入せずにすべて自前のコードで構成する場合は気にならないかもしれませんが、複数のライブラリを組み合わせるときに顕著となります。独自のエラーハンドリング・ライブラリと、エラー用の型定義を作ったとしても、そこに追加で導入する他のライブラリがその独自のエラー機構に準拠していることはまず期待できず、そのライブラリは普通に例外をthrowしてきます。

また、開発者が複数人混ざる場合にも懸念があります。ECMAScriptやTypeScriptをよく理解している開発者だとしても、その参入先の現場の「独自のエラーハンドリング機構」には馴染みがないかもしれません。そういった場合は、まず仕組みを学ぶという段階をひとつ踏む必要がでてきます。

完璧なフォローはコストが高い

これは筆者の私見でしかないのですが、他のライブラリのエラーを全く取りこぼすことなく独自のエラーハンドリング機構に組み込み、かつ出入りする他の開発者のキャッチアップも怠らないというサポートコストは、その独自エラーハンドリングを導入する利点よりも明らかに上回っていると判断しています。

開発者のキャッチアップへのフォローはさておき「他のライブラリのエラーを全て取りこぼさず独自エラーハンドリング側に寄せる」という手配は、ECMAScriptを実行する上で悪魔の証明に近いことであり、とても困難です。

そのため筆者は、ECMAScriptが古典的に備えるthrowtry-catchを使うようにしています。その構文や機構にどれだけ不満があろうとも、今のECMAScript処理系がそうなのだから、それは仕方のないことです。

catchブロック内での型付け

TypeScriptではcatchブロックにおけるエラー変数(ここではerrとする)の型は、かなり長い間any型として扱われていました。どこから何が投げられるかわからないという事情でany型であることはやむを得ないです。

any型は、TypeScript登場から現在までを追ったとき、初期〜中期は稀に登場しても仕方ないものとして扱われていた傾向がありました。流れが変わったのはTypeScript 3.0でのunknownの誕生以降です。ここから、any型は原則的に忌避すべきものという潮流に変わっていったように感じます。やむを得ない事情であっても、そこはany型ではなくunknown型を使うべきという風潮が生まれたためです。

それからも、しばらくはcatchでの変数errany型のままでした。TypeScript 4.0でようやくunknown型の変数アノテーションを付与できるようになります。

そしてさらに時は流れて、TypeScript 4.4でついにstrict有効時のデフォルトで、変数errはアノテーションなしにany型からunknown型として扱われるようになりました。古典的なtry-catchのままではありますが、着実にTypeScript側の扱いは進化してきたのです。

catcherrError型インスタンスとは限らない

catchブロックの変数errunknown型として扱われるということは、それが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)を使うことで変数errErrorのオブジェクトだと確定し、err.messageを参照できるようになります。

暗黙的にerr: Errorであると扱えない理由

ここで疑問を持つ方もいるかもしれません。エラーのキャッチなんだから、errは常にError型としておけば楽なのでは?ということです。これには、そうできない事情があります。

ECMAScriptでは、throwできる値としてECMAScriptプログラム上で扱えるあらゆる値であると定義しています。これは仕様書から明らかです。

https://tc39.es/ecma262/multipage/ecmascript-data-types-and-values.html#sec-throwcompletion

The abstract operation ThrowCompletion takes argument value (an ECMAScript language value)

https://tc39.es/ecma262/multipage/ecmascript-data-types-and-values.html#sec-ecmascript-language-types

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においては変数errunknown型であるという事情が全体的な設計を左右していると感じます。もしerr.messageを使ったメッセージによるエラー分岐をしようとすると、if (err instanceof Error && err.message.includes())と書かないと、TypeScript上では変数errmessageincludes()も生えているとして扱えないためです。

変数errany型だった時代は、たしかにいくつかの方法論を取りうると感じました。そこから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

面白いアドベントカレンダーをありがとうございます! 一点気になったのでコメントします。

筆者の参加する案件では、近年はもっぱらextends Errorを伴った拡張エラークラスを使用するようにしています。例えば次のような実装です。

class ForbiddenError extends Error {}
class InternalServerError extends Error {}

メソッドの実装もconstructorの実装もなく、ただextends Errorを書き加えたのみです。

個人的にはここに更にインスタンスのnameプロパティに書き込むようにしています。例えば以下のような感じです。

class FooError extends Error {
  name = "FooError";
}

こうすることでスタックトレースの文字列にどのエラーが発生したかを残すことが出来ます[1]。参考にしていただけたら幸いです。

脚注
  1. スタックトレースはECMAScriptに記載されておらず実装依存なのですが、基本的な処理系であるV8、SpiderMonkeyそしてJavaScriptCoreでnameプロパティが使われることを確認しました。 ↩︎

とてもわかりやすいですね。ご紹介ありがとうございます。

ログインするとコメントできます