🚨

【TypeScript】カスタムエラーのすすめ

に公開

TypeScriptで開発をしていると、APIエラーやバリデーションエラーなど、さまざまなエラーを扱う場面があります。
そんなときに、標準のErrorクラスだけで対応していませんか。

この記事では、カスタムエラーを導入するメリットと、ボイラープレートを減らしてカスタムエラーを楽に定義出来るライブラリを紹介します。

カスタムエラーを作る理由

標準のErrorクラスを使用することで楽にエラーを作成できますが、次のような問題があります。

  • エラーの種類を区別しづらい
  • 追加の情報(HTTPステータスやエラーコードなど)を持たせづらい
  • メッセージが一貫しない

たとえば次のような例を考えてみましょう。

try {
  throw new Error('User not found');
} catch (error) {
  if (error.message.includes('not found')) {
    // ...
  }
}

これでは、エラーの種類を文字列で判定することになり、変更に弱く、可読性も低くなります。
エラーを明確に区別したい場合に、カスタムエラーが役に立ちます。

カスタムエラーの作り方

まずはライブラリを使わずに、シンプルなカスタムエラーを作る方法を見てみましょう。

class NotFoundError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'NotFoundError';
  }
}

try {
  throw new NotFoundError('User not found');
} catch (error) {
  if (error instanceof NotFoundError) {
    console.error(error.toString());
    // NotFoundError: User not found
  }
}

一見、シンプルに見えますが、実際には、this.nameを毎回設定しなければならないという手間があります。
JavaScriptのErrorクラスは継承時にnameが自動的にクラス名に設定されないため、これを明示的に指定しないと、すべてのカスタムエラーのnameが単にErrorになってしまいます。

Errorクラスのnameプロパティは、エラーを識別するときにも使えますが、実はtoString()を呼び出したときにも利用されます。
具体的には、以下のような違いがあります。

class NotFoundErrorWithName extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'NotFoundError';
  }
}

class NotFoundErrorWithoutName extends Error {
  constructor(message: string) {
    super(message);
  }
}

const errorWithName = new NotFoundErrorWithName('User not found');
console.log(errorWithName.toString());
// NotFoundError: User not found

const errorWithoutName = new NotFoundErrorWithoutName('User not found');
console.log(errorWithoutName.toString());
// Error: User not found

このように、nameプロパティを正しく設定しないとError: ...のような出力になり、どの種類のエラーなのかが分かりづらくなってしまいます。

次に、カスタムエラーに追加の情報を持たせる例を見てみましょう。たとえばプロパティを追加したいときはこうなります。

class ValidationError extends Error {
  constructor(public field: string, public value: unknown) {
    super(`Validation failed: ${field}=${value}`);
    this.name = 'ValidationError';
  }
}

try {
  throw new ValidationError('email', 'invalid-email');
} catch (error) {
  if (error instanceof ValidationError) {
    console.error(error.toString());
    // ValidationError: Validation failed: email=invalid-email
  }
}

このように、カスタムエラーに追加のフィールドを持たせることもできますが、コンストラクタが煩雑になり、ボイラープレートが増えていくのが現実です。

ライブラリを使ってボイラープレートを減らす

ここまでで、カスタムエラーを作る際の手間やボイラープレートの問題点が見えてきました。
これらの問題を解決するのが、@praha/error-factoryです。
このライブラリを使えば、わずか数行で型安全なカスタムエラーを定義できます。

基本的な使い方

まずは、npmpnpmなどお好みのパッケージマネージャーでライブラリインストールします。

npm install @praha/error-factory

次に、以下のようにしてカスタムエラーを定義します。

import { ErrorFactory } from '@praha/error-factory';

class NotFoundError extends ErrorFactory({
  name: 'NotFoundError',
  message: 'Resource not found',
}) {}

throw new NotFoundError();

ErrorFactory関数は、クラスを返す高階関数で、namemessageを設定したクラスを生成します。
これは、下記の記事の内容を応用した物になります。
https://zenn.dev/praha/articles/4ef2bb7ca69e5a#動的class定義を活用する

causeオプションのサポート

ECMAScript 2022から追加されたcauseオプションもサポートしています。

class DatabaseError extends ErrorFactory({
  name: 'DatabaseError',
  message: 'A database error occurred',
}) {}

throw new DatabaseError({ cause: new Error('Connection failed') });

このように、causeオプションを使うことで、エラーの原因をチェーンすることでデバッグが容易になります。

高度な使い方

より高度な使い方として、動的なメッセージ生成や追加のプロパティを持たせることも可能です。

カスタムフィールド定義

次のように、ErrorFactoryfieldsメソッドを使って、追加のフィールドを定義できます。

class QueryError extends ErrorFactory({
  name: 'QueryError',
  message: 'An error occurred while executing a query',
  fields: ErrorFactory.fields<{ query: string }>(),
}) {}

throw new QueryError({ query: 'SELECT * FROM users' });

動的なメッセージ生成

また、指定したフィールドを使って動的にメッセージを生成できます。

class ValidationError extends ErrorFactory({
  name: 'ValidationError',
  message: ({ field, value }) => `Validation failed for ${field}: ${value}`,
  fields: ErrorFactory.fields<{ field: string; value: unknown }>(),
}) {}

throw new ValidationError({ field: 'email', value: 'invalid-email' });

パターンマッチング

ErrorFactoryに指定したnameは型ガードにも利用出来ます。
そのため、ts-patternなどのパターンマッチングライブラリと組み合わせて使うこともできます。

import { ErrorFactory } from '@praha/error-factory';
import { match } from 'ts-pattern';

class NotFoundError extends ErrorFactory({
  name: 'NotFoundError',
  message: 'Resource not found',
}) {}

class ValidationError extends ErrorFactory({
  name: 'ValidationError',
  message: 'Validation failed',
}) {}

type ApplicationError = NotFoundError | ValidationError;

const handleError = (error: ApplicationError) => {
  match(error)
    .with({ name: 'NotFoundError' }, (e) => {
      console.error('Handle NotFoundError:', e.toString());
    })
    .with({ name: 'ValidationError' }, (e) => {
      console.error('Handle ValidationError:', e.toString());
    })
    .exhaustive();
};

まとめ

カスタムエラーを使うことで、エラーの種類を明確に区別し、追加情報を持たせることができ、コードの可読性と保守性が向上します。
しかし、手動でカスタムエラーを定義するとボイラープレートが増えてしまうことも事実です。

もしこの記事を読んで、同じようにカスタムエラーのボイラープレートに悩んでいる方は、ぜひ@praha/error-factoryを使ってみてください。
気に入ってもらえたら、GitHubのレポジトリにスターを付けていただけると嬉しいです。
https://github.com/praha-inc/error-factory

その他にも、TypeScriptでの開発に役立つライブラリをいくつか公開していますので、ぜひチェックしてみてください!
https://zenn.dev/praha/articles/9310a5d58b84a5
https://zenn.dev/praha/articles/7c202f5906e065

PrAha

Discussion