お知らせ

現在サイトのリニューアル作業中のため、全体的にページの表示が乱れています。

例外設計について普段考えていること

投稿日:
開発::設計言語::TypeScript

例外設計についての個人的に思っていることを書き出してみる。整理できていないがいったん現状。TypeScriptのケースを意識して考えているが、根幹は例外スローのある言語ではどれも共通だと考えている。

例外とは何か?

本記事で扱う例外とはthrow new Error("ほげほげ");のように、いわゆるスローされ、try-catchされるものを指して扱う。

例外を多用しない

例外は多用しないことが望ましく、原則として処理が続行不能になる場合を除き、使わないほうが良いと考えている。これはスローされた例外は可視化されづらく、適切にハンドリングされないケースがよくあるからだ。

例外は構造化されたプログラムを破壊する

構造されたプログラムとは順次・反復・分岐の基本構造を階層的に組み合わせたものであり、いわゆる上から下に読めばわかるもの、構造化プログラミングによって作られるものだ。上から下に流れるため非常にわかりやすい。

これに対して存在するのがgotoを用いたプログラムだ。gotoを使うとコードの色んな場所に前後しながらジャンプすることが可能で、可読性を損ねてしまう。

そして例外は基本的にgotoのような存在である。例外が起きると例外オブジェクトが投げられ、これはどこに行きつくか予想することが難しい。行きつく先がなければ最悪プログラムがクラッシュする恐れすらある。

例外は処理コストが重い

恐らく大抵の言語において例外をcatchする行為はコストが重い。これはJavaでは特に有名な話だと思うが、Java以外でもそうだと思う。例えばNode.jsでtry-catchで例外を処理するプログラムと、if-elseで処理するプログラムを書き、その実行速度を比較するとif-elseの方が早い。

以下はエラーハンドリングをifで行うプログラムと、try-catchで行うプログラムを作り、100万回走行させたときの処理時間だ。catchに入るケースでは処理速度が低下することが分かる。例外が投げられず、tryの中に納まる限りは低下しない。

処理 処理時間(ms)
If正常系 4,453
If異常系 4,204
try-catch正常系 4,341
try-catch異常系 7,883

これは例外が投げられる場合、最寄りのcatchポイントを探索するのに時間がかかるからではないかと考えている。

Node.js向け検証コード

前述の処理時間を出すのに使ったプログラム

// ==== 実行用共通部品
const execIf = (input) => {
  if (input === 1) {
    return true;
  } else {
    return false;
  }
};

const execThrow = (input) => {
  if (input === 1) {
    return true;
  } else {
    throw new Error('ERR');
  }
};

// ==== コールバック処理計測用関数
const getExecFuncElapsed = (cb) => {
  const startAt = +new Date();
  for (let i = 0; i < 1_000_000; i++) {
    cb();
  }
  return +new Date() - startAt;
};

// ==== If/try-catch検証用、コールバック処理群
const execIfOk = () => {
  const ret = execIf(1);
  if (ret) {
    console.log('OK=');
  }
};

const execIfNg = () => {
  const ret = execIf(0);
  if (ret === false) {
    console.log('ERR');
  }
};

const execThrowOk = () => {
  try {
    execThrow(1);
    console.log('OK=');
  } catch (e) {
    console.log('ERR');
  }
};
const execThrowNg = () => {
  try {
    execThrow(0);
    console.log('OK=');
  } catch (e) {
    console.log('ERR');
  }
};

// ==== 計測処理
const elapsedIfOk = getExecFuncElapsed(execIfOk);
const elapsedIfNg = getExecFuncElapsed(execIfNg);
const elapsedThrowOk = getExecFuncElapsed(execThrowOk);
const elapsedThrowNg = getExecFuncElapsed(execThrowNg);

// ==== 結果出力
console.table({ elapsedIfOk, elapsedIfNg, elapsedThrowOk, elapsedThrowNg });

いつ例外を使うか?

基本的には大域脱出がしたいケースのみで使うべきだろう。最悪ハンドリングされずとも、基底階層でcatchされれば、それでよいケースで使うのが最も無難だと考える。

大域脱出とは要するにgotoだ。多重ネストや深い高階関数から浅い階層に一気にすり抜けるときに有効だろう。逆に一階層とか、抜ける階層が浅いレベルでは使わないほうが良い。

例えば処理が続行不能になったケースでは例外をスローし、基底階層でcatchし、エラーログを吐く、クライアントに対してエラーメッセージを返すなどの処理があればよいと考える。

但し例外を使うときは極力カスタム例外を使ったほうがよいと考える。

フレームワークやライブラリの例外をどう扱うか?

例えばバリデーションエラーやHttpClient系の4xx, 5xx系の例外スローはラッパーを作り、例外をエラーオブジェクトに変換するのも一つだと考える。

HTTP GETを行うクライアント関数であれば以下のようなラッパーを作り、正常時は正常レスポンスを返し、異常時でかつ想定内であればエラーオブジェクトを返す、そして想定外の例外であればリスローする、といった処理をすることができる。

const httpGet = (url) => {
  try {
    return fetch(url);
  } catch (e) {
    if (e instanceof AbortError) {
      return createHttpErrorObj(e);
    } else {
      throw e;
    }
  }
}

こうすることで呼び出し元は正常時であればHTTPリクエストをそのままステータスコードに応じて処理し、Abortされた場合はリトライ、完全に予期せぬ内容であれば例外を基底階層まで飛ばして処理を中断するといったこともできる。

リトライに規定回数失敗した場合は、この関数の呼び元でnew OutOfTimesHttpRetryError()みたいな例外をスローするとよいだろう。

カスタム例外を使う

カスタム例外とは例外クラスを継承した例外クラスだ。

例えばC#ではExceptionのほかに、それを継承したSystemExceptionや、IndexOutOfRangeExceptionなど、様々なカスタム例外が存在する。

LaravelにもAuthorizationExceptionをはじめとし、多様なExceptionが存在する

これらに限らず、大抵の言語やフレームワークには相応のカスタム例外が用意されているのが一般的で、業務システムやC2のプロダクトでも、例外をスローするケースではカスタム例外を作ることが望ましいと考える。

カスタム例外があると例外種別ごとにフィルタリングすることが可能になり、柔軟にハンドリングしやすく、ロギングの際にも例外種別を記録することで、後々の障害調査でも便利になるため有用だ。

例外を握りつぶさない

例外は時として握りつぶされることがある。そういう必要があるケースも少なからずあるだろう。しかし基本的に例外は握りつぶさないほうがよい。

例外は望ましくない現象が起きているはずで、本質的にハンドリングする必要がある。単に握りつぶしているだけでコメントやテストも書かれていなければ、もし例外が起きたとき、処理が正常に進むのかどうかコードから読み取ることが困難だ。

仮にテストがあったとしてもコードを読むときのコストが増えるので、基本的に握りつぶさないほうがよいだろう。

スローする場合は、例外クラスを用いる

JavaScript系では以下のようなエラーオブジェクトを作るケースもあると思うが、スローする場合は使わないほうが良い。

{
  __type: 'HogeError',
  message: 'fufagfuga',
  payload: someObject
}

paste-image-2025-57-4_12-56-48-850.png

スローする場合はErrorクラスを継承したカスタムエラーをスローすべきだ。これはカスタムエラーにはスタックトレースなど、障害調査をするときなどに例外として標準的な情報が格納されているほか、instanceofでフィルタしやすい、クラスは型情報を持つのでリファクタが容易、error.__type === 'HogeError'は言語機能の再実装に近く標準的ではないからだ。テストフレームワークでも例外をキャッチするためのアサーションではErrorクラスの継承が必須となっているケースがあるため、TypeScriptでは型検査が上手く通らないケースがある。