TypeScriptでカスタムエラークラス設計・実装

エラーの設計・実装方法について社内に共有する機会があり、設計の流れや一例を書いてみました。TypeScriptとは言っていますが、なるべく言語非依存・ライブラリ未使用で、カスタムエラークラスを使用する前提で書きました。


目次

目次


概要

エラーが起きたとき、色々やりたいことありますよね。でもデフォルトErrorクラスでは扱いきれないことも多々あります。。。

カスタムエラークラスなら色々できる!

でも、どうやって・・・?


そもそもエラー時って

何が知りたいか

何が起きたか

  • ○○が失敗した
  • データがおかしくなっていた
  • アプリケーションが落ちた
  • DB・サーバと繋がらなかった

どこで起きたか

原因・種別は何か

  • アプリとしてのエラー仕様
    • バリデーションエラー
    • 許可されていない処理のエラー(≒一部認可とも共通)
    • データの重複エラー
  • コード自体のエラー
    • undefined, nullの想定外の挙動・null pointer
    • 単なるコードのミス
    • 想定外のエラー
    • Uncaught Error
    • メモリリーク
    • ランタイムのバグ・エラー
  • ミドルウェアエラー仕様
    • 同時接続数オーバー
    • 外部API・サーバーエラー
    • DBのロック競合・timeout到達
    • ミドルウェアのコンフィグ・設定ミス
  • その他
    • とにかく何かがおかしい
    • でも何が何だかわからない


何がしたいか

デフォルトのErrorクラスが含むもの以外の情報を盛り込みたい

  • エラー時に、ログからフィルタリングしやすいようにしたい
  • 複数のエラーコード要素を用意したい(エラーコード・エラータイプで分ける的な)
  • クライアントに返すメッセージとシステムで出力するメッセージを分けたい
  • デバッグモードのときとプロダクションモードのときで処理を分けたい
  • エラーの原因と箇所が知りたい

アプリケーションの各エラーを共通でハンドリングする処理が書きたい

  • メッセージの形式を統一したい
  • 独自のエラーコードごとに処理を共通化したい
  • エラーコード・種別だけ渡す仕様にして、エラーメッセージはビジネスロジック中に書かずに別のところで共通で定義したい

エラーの種類によって処理を変えたい

  • エラータイプ・エラーコードごとにハンドリングを変えたい

エラー時に、処理のカバリングがしたい


カスタムエラー設計・実装の一例

前提

  • 説明重視のためライブラリは使っていませんが、いいライブラリが各言語にあったりします。
    • が、あくまでそれらはエラー設計から実装に落とし込むステップを短縮・簡易化するための手段なので、エラー設計の思想や実装の流れを理解しておくのは重要です。
  • サンプル実装はなるべく言語非依存・汎用的な実装にしています。
    • 実装自体はTypescript(javascript: es2020)で記載
  • あくまで一例なので、これが正解というわけではなく他にも色々な方法があります。


クラス例などのコードサンプルはGitHubにあります。

github.com


エラー設計・実装の流れ

  • ①エラーの全体設計
  • ②エラークラス自体の実装
  • ③ロジック側のエラー呼び出しの実装


①エラーの全体設計

1. エラー種類・エラー出力内容の洗い出し・分類

どんなエラーがあるか・作りたいか(= エラーコード・エラーメッセージなどのマッピング)をします。

ここで大事なのは、ざっくりでいい・何ならなくてもいいです。

エラー仕様書があれば作るとか、エラーコードまでいかずとも大まかなカテゴリでもいいです。

    • Already Exists Error:「データがあります」
    • Not Exists Error:「データがありません」
    • Authentication Error:「ID・Passowrdが違います」
    • Unknown Error:「エラーが発生しました」


ここでのメッセージはクライアントに返すものでもいいし、システム内で出力するものでもいい・両方でもいいです。


2. エラー生成時に行いたいこと(振る舞い・処理)の洗い出し

ログ出力・リカバリーなど、エラーが起きた時にシステムで処理したい・しなければいけないことを洗い出します。


3. メッセージ形式決め

ここでもまた、ざっくり・テキトーでも良いです。

形式がどうとかより、通化しておくということが大事です。

「キー:バリューの羅列」という形式(ルール)だけ決めておくでもいいです。


logger系の使用予定があればそれに合わせてください。



②エラークラス自体の実装

1. Errorクラスを継承して、共通エラークラス(ベースクラス)として作成

このベースクラスは、Errorクラスを継承し、そのアプリ用の共通のエラー形式・処理などをラップするものという存在です。

エラークラスが増えても各エラーで共通処理ができるように、各カスタムエラークラスのスーパークラスとして用意しておきます。


  • 構成
    • フィールド
      • エラーコード(エラーメッセージ配列のキーとして使えるもの)
        • 通常のErrorクラスはコンストラクタにエラーメッセージを渡すがここでは渡さず、エラーメッセージ配列のキーとして使えるものをコンストラクタに渡して、出力するエラーメッセージ(文章)は別で共通で定義する
          • それぞれの呼び出し元でエラーメッセージを書いていると各場所で違う表現になったりするため
          • 英語で書いていたり日本語で書いていたり、単語だけだったり文章で書いてあったり
      • Errorインスタンス
        • 自前エラーだけじゃなく外部モジュールが吐くerrorインスタンスもラップできるように
          • ラップする必要がないならいらない
    • コンストラク
      • 上記フィールドのセット
        • constructor(message)は空で初期化
          • messageをエラーコードとして扱うならそのまま格納するのもあり
        • エラーコードをもとにエラーメッセージを取得して、スーパークラスのError.messageにセット
          • messageをエラーコードとして扱うなら別フィールドを用意
      • エラー生成時に行いたい共通・メッセージ出力処理などを呼び出し
        • 処理自体はprivateメソッドで書いたり、abstractにしてサブクラスで書いたり
        • ※Errorインスタンス生成時に処理を走らせるのではなくcatch内で処理したい場合は、コンストラクタに入れない
    • メソッド
      • とりあえずメッセージ出力メソッドだけ作っておく
      • リカバリ処理もabstractで作っておいてもいいし、各サブクラスで必要なときに実装でも
        • abstract, overrideで色々やりようがある
        • 各処理メソッドをabstractで定義しておいて、各メソッドをまとめて呼ぶ集約メソッドを作って、各処理の詳細はサブクラスで実装すると、全体の処理の流れは共通化しつつ各処理は柔軟に変えられるので便利
      • 「エラーコードをもとに、エラーメッセージ一覧からエラーメッセージを取得」する処理をここで作っておいても良い


ベースクラスの実装サンプルは以下になります。

export abstract class CustomBaseError extends Error {
    private errorCode: string;
    private moduleError: Error | undefined;

    constructor(errorCode: string, e?: Error) {
        super(); //this.messageはここではいれないが、super()は必須なので空で初期化
        this.errorCode = errorCode;
        this.moduleError = e;
        this.message = this.getMessageByErrorCode();

        this.describeMessage();
    }

    // アプリケーションのエラー仕様に合わせて出力形式を整える
    // logger使って構造化ログ(json)で出力するのが本当はオススメ
    // そこらへんの形式などもここで統一化し、各エラークラスで共通化する
    private describeMessage(){
        const errorCode = this.errorCode;
        const errorType = this.constructor.name;
        const errorCategory = this.moduleError ? 'ModuleError' : 'ApplicationError';
        const moduleErrorMessage =  this.moduleError ? this.moduleError.message : '';
        const errorMessage =  this.message;

        console.error('ErrorCode: ' + errorCode);
        console.error('ErrorType: ' + errorType);
        console.error('ErrorCategory: ' + errorCategory);
        console.error('ModuleErrorMessage: ' + moduleErrorMessage);
        console.error('ErrorMessage: ' + errorMessage);
    }

    private getMessageByErrorCode(): string {
        // メンバ変数にしてコンストラクタ内での初期化などもOK
        const errorMessageMapping = new ErrorMessageMapping();

        return errorMessageMapping.getErrorMessage(this.errorCode);
    }
} 


2. 各エラー種別ごとにベースクラスのサブクラスとして作成

まずサブクラスの枠組みを作成します。エラー仕様書などがあればそれをもとに分類・作成します。

DBError + OtherErrorとかの2つでもいいし、何なら最初は一つでもいいです。

とりあえずベースクラスと実装用のサブクラスを1つ用意しておくだけで、共通土台を作りながらも簡単に拡張ができるようになりますし、必要に応じて増やすのも無駄がなくて良かったりします。


  • エラー種別(サブクラス)の洗い出し方の例:
    • エラーコードの共通点をもとにグルーピングする
    • 振る舞い・処理ごとにグルーピングする
      • DB, Network, Server, etc...
      • Transaction, Lock, etc...
      • 4xx, 5xx
      • Fatal, Warn, Info, etc...


そして必要があれば、リカバリ処理の実装も行います。

特に処理がいらない場合 or エラークラス内でやるのではなくcatch内で毎回エラークラスとは別で処理を呼びたいような場合は、作らなくて良いです。


またリカバリ処理を実装する際に、その処理をコンストラクタで呼ぶ例を上記の方で挙げましたが、リカバリ処理をコンストラクタで呼ばずとも、メソッドだけ作ってcatch内でそのメソッド呼び出すようにするも便利です。

その場合、「エラーハンドリングは上流にthrow(エスカレーション)して共通で行う」セオリーもあったりするので、一番呼び出し元のcatchでリカバリ処理を呼び出すなどもありです。


ただしエラークラス内で共通でリカバリ処理などの実装/呼び出しをすることにはメリット・デメリットが存在します。

  • メリット
    • catch内にロールバック処理など共通で行う処理を書かなくて済む
      • ORM・ライブラリによる差異もエラークラス内で吸収できる
    • そのサブクラスが呼ばれたら共通でリカバリ処理が走るようにできる
  • デメリット
    • エラークラスに処理が入ることになるので、責務が分散してわかりづらくなる
      • 異常時のカバリングは例外と考えるとまあ許容もできる


サブクラスの実装サンプルは以下になります。

// DBのエラー系のカスタムサブクラス
export class DBError extends CustomBaseError {
    constructor(errorCode: string, e?: Error) {
        super(errorCode, e);
        this.doRecovery();
    }
   
    private doRecovery(){
        this.rollbackTransaction();
        this.releaseLock();
    }

    private rollbackTransaction(){
        console.error('~トランザクションのロールバック処理します。~');
    }

    private releaseLock(){
        console.error('~確保していたロックを解放します。~');
    }
}

// また別のカスタムサブクラス(あくまで例なのでテキトーです)
export class OtherError extends CustomBaseError {} // 何かあればいろいろ追加


3. エラーメッセージのマッピングクラスを作成

こちらは、クラスやtype、連想配列や定数などで表現します。

このためにインスタンス生成するよりかは静的に行う方がスマートだったりもしますね。また書き換え不可能なものですし、定数の方が本来は良かったりもします。


また、ここでマッピング層を作らずとも、最初は1.のベースクラスに埋め込みでも良いです。

後から複雑になってきた時に切り出すとかでも十分対応は可能だと思います。


マッピングクラスの実装サンプルは以下になります。(※クラス + 連想配列で書いていますが、あくまでもサンプルです。)

export class ErrorMessageMapping {
    private mapping: { [key: string]: string; } = {};

    constructor(){
        this.mapping['A001'] = 'エラーメッセージA001になります。'; 
        this.mapping['A002'] = 'エラーメッセージA002になります。'; 
        this.mapping['A003'] = 'エラーメッセージA003になります。'; 
        this.mapping['A004'] = 'エラーメッセージA004になります。'; 
        this.mapping['A005'] = 'エラーメッセージA005になります。'; 
    }

    public getErrorMessage(mapKey: string): string {
        return mapKey in this.mapping ? this.mapping[mapKey] : 'Other Error.';
    }
}



③ロジック側のエラー呼び出しの実装

最後に、先程作ったエラークラス(ベースクラス + 種別ごとのサブクラス)をロジック中で呼び出す部分です。


try内でエラー生成

具体的には、tryで囲んだブロック内のエラーを起こしたい各場所に、カスタムエラー生成を盛り込みます。

throw new xxError('ErrorCode');


catchで分岐

そして、生成・スローしたエラーをcatchし、上記で作成したカスタムエラークラスのインスタンスかどうかで分岐してハンドリングをします。


  • 分岐①:try内で自前でnewしたCustomBaseErrorのthrow
// try内でnewしているのでここではスローだけ

if (e instanceof CustomBaseError){
  throw e;
}


  • 分岐②:try内で外部モジュールで生成されたエラーをCustomBaseErrorにラップして再new/throw
// try内で発生した外部モジュールなどのErrorをカスタムエラーでラップ

else { // elseかifかは分岐の仕方による
//else if (e instanceof Error){
  throw new yyError('ErrorCode', e as Error);
}


補足

  • typeofはErrorクラスに対応していないのでinstanceofを使います。
  • 上記の分岐によって、動的に外部モジュールのエラーをカスタムエラークラスにラップして共通のハンドリングをすることが可能になります。
  • 「モジュールが吐くエラーでもカスタム生成したエラーでも、共通のサブクラスを呼びたい」ような場合は、上記分岐例以外に各モジュールのErrorクラスも条件に加えます。
if (e instanceof Prisma.PrismaClientError){
  throw new DBError('ErrorCode', e as Prisma.PrismaClientError);
}



エラー呼び出しの実装サンプルは以下になります。

基本例(書き方の概略)とtry/catchでの実装例(実際のコードを意識した例)で2つずつに分けています。


基本例 : 他で生成されたデフォルトErrorをカスタムエラーにラップしてハンドリング

/** 
 * 他で生成されたデフォルトErrorをカスタムエラーにラップしてハンドリング
 * <<出力内容>>
ErrorCode: A001
ErrorType: DBError
ErrorCategory: ModuleError
ModuleErrorMessage: Any module error has occurred.
ErrorMessage: エラーメッセージA001になります。
~トランザクションのロールバック処理します。~
~確保していたロックを解放します。~
 */

const err = new Error('Any module error has occurred.');

const appErr = new DBError('A001', err);


基本例 : カスタムエラークラスのサブクラス「OtherError」をnewする

/** 
 * カスタムエラークラスのサブクラス「OtherError」をnewする
 * <<出力内容>>
ErrorCode: A002
ErrorType: OtherError
ErrorCategory: ApplicationError
ModuleErrorMessage: 
ErrorMessage: エラーメッセージA002になります。
 */

const appErr = new OtherError('A002');


try/catchでの実装例 : アプリケーションエラー(自前でthrow newする)を生成

/** 
 * try/catchでの実装 : アプリケーションエラー(自前でthrow newする)
 * (catch内でifにいく)
 */
try {
    const a = 3;
    const b = 2;
    // 何かアプリ的に失敗なとき、自前でnewしてそのままスロー
    if ( a > b ) {
        throw new DBError('A003');
    }
} catch (e) {
    if (e instanceof CustomBaseError){
        // <ここに飛ぶ>
        // そのままスロー
        throw e;
    } else {
        // モジュールが吐いたエラーをカスタムエラークラスに渡して生成
        throw new OtherError('A004', e as Error);
    }
}


try/catchでの実装例 : 外部モジュールエラー(他で生成されたError)をラップ

/** 
 * try/catchでの実装 : 外部モジュールエラー(他で生成されたError)
 * (catch内でelseにいく)
 */
try {
    // 例:DB接続でエラーが起きてMySQLコネクタによるErrorが返ってきた
    // (動作確認のため普通のErrorを生成してます。)
    throw new Error('DBとか外部モジュールのエラーになります。');
} catch (e) {
    if (e instanceof CustomBaseError){
        // そのままスロー
        throw e;
    } else {
        // <ここに飛ぶ>
        // モジュールが吐いたエラーをカスタムエラークラスに渡して生成
        throw new OtherError('A005', e as Error);
    }
}



最後に

かなり文章量は多くなってしまいました。 今回書いている間に色々ググったりもしてみたのですが、確かにあまりエラーの設計・実装に関しての記事って見たことないなあと。

カスタムエラークラスの基本的な書き方はよく見るけれど、それを実際にどう使っていくのかというところまで踏み込んだものがあまりなかったので、いい機会になったのではと思います。

上記の設計・実装の流れはあくまで一例なので、その他の方法もたくさんあり、もっと良い部分もたくさんあるかとは思います。