🤯

俺流Typescriptでのエラー処理

2023/07/08に公開

皆さん悩み多き部分のようです。ちょっとググっても、どうするかという記事がたくさん出てきます。
https://qiita.com/frozenbonito/items/e708dfb3ab7c1fd3824d
https://dev.classmethod.jp/articles/error-handling-practice-of-typescript/

いわゆるResult型、Either型を使うのがよさそうという雰囲気の記事が多いように感じます。
個人的には、そういった抽象度の高い仕組みを使う際には、作り込まれたライブラリを使いたいので、fp-tsの利用が候補に上がりそうです。

ただ、tsを初めてprivateで数カ月書いてみたのですが、ちょっとその段階でfp-tsに手を出す気にはなりませんでした。
いわゆる関数型スタイルは良いのですが、言語の支援がない状態では少し書きづらそうなのと、どうしても変数が出てきづらいので何を扱っているのか迷子になってしまいそうです。
大きめのコードベースをいじる際には、この手の考え方に慣れつつライブラリを部分的にでも適用するのが良さそうですが、もう少しライトな書き方がしたいなと考え、まとめて見ました。

あくまで自分の好みの書き方なので、粗はたくさんあると思います。また、ここではResult型やEither型はでてこないです。

エラーの定義

classを使います。僕個人は、classのようなややこしい言語機能をあんまり使いたくないのですが、エラーであることの判定にinstanceofを使いたく、tsではなくjsでも認識できる型が欲しいので導入します。

class DataNotFoundError {
  constructor(
    readonly dataName: string,
    readonly storeType: string,
    readonly message: string
  ) {}
}

class自体があまり好きではないので、classの機能としては控えめに使います。
特にメソッドなどは生やさず、js objectの用にproperty accessのみできる形です。

js自体にError classがあるので継承しても良かったのですが、継承はもっと嫌いなのと、開発者が定義するエラーとは別と考えたかったので、敢えて継承はしてません。
Javaの検査例外のようなイメージで、ハンドリングされるものと、ハンドリングされずに即システム停止するものを分けたかった感じです。
(Javaの検査例外は、辿っていくと非検査例外と同じThrowableを継承していますが…)

関数の定義

こんな感じで定義します。

type GetData = (name: string) => Data | DataNotFoundError
const getData: GetData = name => {
  const data = repository.get(name);
  if (!data) {
    return new DataNotFoundError(name, 'repository', `${name}というdataは存在しません`);
  }
  return data;
}

今回はDataNotFoundErrorだけですが、関数によっては複数のエラーが発生する可能性もあり、型定義は長くなります。
そこで、関数の型定義は、分離しています。
型の定義は複数の関数に適用することもあり、その際にも有用ですがが、この書き方は自分の好みの部分が大きいですね。

ただ、これには問題があって、Genericsを使った型定義が難しいです。
以下の用に書けないようです。(できるのですかね?3ヶ月書いただけだとわかりませんでした。)

type GetData<T> = (name: string) => T | DataNotFoundError
const getData: GetData = <T>name => {
  const data = repository.get<T>(name);
  if (!data) {
    return new DataNotFoundError(name, 'repository', `${name}というdataは存在しません`);
  }
  return data;
}

こういう場合は仕方ないので、関数の型は同時に定義します。
1行が長くなりがちですが仕方ありません。

const getData = <T>(name: string): T | DataNotFoundError => {
  const data = repository.get<T>(name);
  if (!data) {
    return new DataNotFoundError(name, 'repository', `${name}というdataは存在しません`);
  }
  return data;
}

エラーハンドリング

ようやくハンドリングです。

const data = getData(name);
if (data instanceof DataNotFoundError) {
  // do something when error
  return data;
}

console.log(data); //Data型の変数として扱える

getDataの戻り値型は、Union型となっており、DataともDataNotFoundErrorとも扱えなくてはなりません。
typescriptではinstanceofで判定されるif分岐はtype guardとして働くので、instanceofで判定すれば、以降はそれぞれの型で扱えます。
例外のように一気にcall stackを戻るのではなく、各呼び出し階層で判断し処理します。

エラーのパターンが増える場合は、こんな感じで増やしていきます。

type GetData = (name: string) => Data | DataNotFoundError | AnotherError
const data = getData(name);
if (
  data instanceof DataNotFoundError ||
  data instanceof AnotherError
) {
  return data;
}

console.log(data);

エラー型の抽象化

エラーのパターンが増えてきたら、継承の仕組みを使えば、instanceofで判定する際にも、抽象的に行うこともできます。
ただ僕は継承が好きではないので、愚直に定義していくほうが好きではあります。

class BaseError {
  constructor() {}
}

class DataNotFoundError extends BaseError {
  constructor(
    readonly dataName: string,
    readonly storeType: string,
    readonly message: string
  ) { super(); }
}

class AnotherFoundError extends BaseError {
  constructor(
    readonly message: string
  ) { super(); }
}

const data = getData(name);
// if分岐を書くのが楽になります
if (data instanceof BaseError) {
  return data;
}

console.log(data);

ただし、関数の型はちゃんと書くべきです。
抽象化した型では、何が起きたのか、起きる可能性があるのかわからなくなってしまいます。

// この書き方はよくない
type GetData<T> = (name: string) => T | BaseError

// こちらが推奨
type GetData<T> = (name: string) => T | DataNotFoundError | AnotherFoundError

非同期関数

非同期の場合も同様です。

type GetData = (name: string) => Promise<Data | DataNotFoundError>
const getData: GetData = async name => {
  const data = await repository.get(name);
  if (!data) {
    return new DataNotFoundError(name, 'repository', `${name}というdataは存在しません`);
  }
  return data;
}

同様にハンドリングします。

const data = await getData(name);
if (data instanceof DataNotFoundError) {
  return data;
}

console.log(data);

rejectやtry catchを使ってもいいのですが、型定義が関数の型に現れず、こちらのほうがわかりやすいと感じます。エディタの支援も受けられます。
try catchやrejectは、回復不可能な例外の扱いのために取っておくという考え方もできます。

ライブラリや実行環境の例外

ライブラリコードが投げる例外は、今回提示した形になっていないのでラップします。

import { repository } from 'db/schema/data';

type GetData = (name: string) => Promise<Data | DataNotFoundError | DatabaseError>
const getData: GetData = async name => {
  try {
    const data = await repository.get(name);
    if (!data) {
      return new DataNotFoundError(name, 'repository', `${name}というdataは存在しません`);
    }
    return data;
  } catch (e) {
    return new DatabaseError(e, 'databaseで問題が発生しました');
  }
}

一々ライブラリをラップするのは面倒ですが、ライブラリをプラガブルにするためにも、1層挟んだほうが後々扱いやすいという側面もあります。

繰り返し

繰り返しを書くときは、ちょっと面倒です。

const datas: Data[] = [];
for (const name of names) {
  const data = getData(name);
  if (data instanceof DataNotFoundError) {
    return data;
  }
  datas.push(data);
}

console.log(datas);

通常、jsを書く際は、Array#mapやArray#forEachなどを使うことが多いとおもいますが、for loopを使いました。
エラーをどう扱うか、全てチェックしてから返すか、一部でもエラーがあれば即returnかなど、様々パターンはあります。

一番簡単な、即returnする場合でも、関数scopeを切ってしまうと、returnがその関数scopeのものになってしまって扱いづらいです。
なので、コード量は少し増えますが、for文で書いています。

僕はgolangを書いたことがないが、書き方はなんとなく見ることがある程度という状態で、golangっぽいエラーハンドリングを取り入れて見ました。
golangでも繰り返しはfor文で書くのが通例だと認識していますが、こういった事情があるのですかね?

eslint

この書き方は癖があるので、eslintの調整も必要です。
Typescript歴3ヶ月なので、airbnbのruleをbaseとしていますが、そこはこだわってません。
追加したruleを説明します。

module.exports = {
  parser: "@typescript-eslint/parser",
  plugins: ["@typescript-eslint"],
  root: true,
  env: {
    node: true,
    es2021: true,
  },
  parserOptions: {
    ecmaVersion: "latest",
    sourceType: "module",
    project: "./tsconfig.eslint.json",
    tsconfigRootDir: __dirname,
  },
  ignorePatterns: ["dist"],
  extends: [
    "eslint:recommended",
    "airbnb-base",
    "airbnb-typescript/base",
    "plugin:@typescript-eslint/recommended-requiring-type-checking",
    "prettier"
  ],
  rules: {
    "import/prefer-default-export": "off",
    "max-classes-per-file": "off",
    "no-restricted-syntax": [
      "error",
      {
        selector: 'ForInStatement',
        message: 'for..in loops iterate over the entire prototype chain, which is virtually never what you want. Use Object.{keys,values,entries}, and iterate over the resulting array.',
      },
      // {
      //   selector: 'ForOfStatement',
      //   message: 'iterators/generators require regenerator-runtime, which is too heavyweight for this guide to allow them. Separately, loops should be avoided in favor of array iterations.',
      // },
      {
        selector: 'LabeledStatement',
        message: 'Labels are a form of GOTO; using them makes code confusing and hard to maintain and understand.',
      },
      {
        selector: 'WithStatement',
        message: '`with` is disallowed in strict mode because it makes code impossible to predict and optimize.',
      },
    ],
  },
};

max-classes-per-file

一つのファイルにclass定義は一つしか書いてはいけないというルールです。

例外の定義はclassを短く書くことで定義しています。
したがって、一つのファイルに複数のclass定義を書いても読みやすさを損ねません。
むしろ、機能的に近いものは近くに書いたほうがわかりやすいため、max-classes-per-fileはoffにしています。

class DataNotFoundError {
  constructor(
    readonly dataName: string,
    readonly storeType: string,
    readonly message: string
  ) {}
}

class AnotherError {
  constructor(
    readonly message: string
  ) {}
}

no-restricted-syntax

ForOfStatementをコメントアウトして、上書きしています。
ForOfStatementは、for文を使用しないというルールです。

一つのコードベース上で、違った書き方の繰り返しが出てくるのは読みやすさを損ねます。
このルール自体は有用なのはわかるのですが、だからといって関数スコープを新しく切って、コードが書きづらくなるよりはマシという考えです。

まとめ

いやはや、僕の癖が出ただけのコードになってしまいました。

エラーを共通の型として定義しては?という疑問がある方もいらっしゃるかもしれませんが、エラーの型とは、何が発生したか一発でわかるべきであり、エラーの内容によってそのpropertyも変わるものであると考えます。
したがって、Result型を定義する場合でも以下のように書くはずだと考えています。

// こう書く
type GetData = (name: string) => Result<Data, DataNotFoundError>

// これだと文字列だけの情報でいろいろしないといけない
type GetData = (name: string) => Result<Data, string>

// これも同様でBaseErrorでは抽象度が高すぎ、文字列だけの情報でいろいろしないといけない
type GetData = (name: string) => Result<Data, BaseError>

せいぜい3ヶ月書いた程度なので、今後もstyleは変化していく可能性はあります。
また若干コード量が増えやすい書き方なので、その点の改善のためにも、自分自身の成長のためにもfp-tsなどを慣れてみたいですね。

Discussion