TypeScript で Result 型を作ってエラーハンドリングを簡単に

TypeScript で Result 型を定義してエラーハンドリングをしやすくしたり、抜け漏れを防ぎやすくしてみました。

やりたいこと

TypeScript でエラーハンドリングの抜け漏れ防止や開発者体験向上のために、Rust の Result 型のように try catch するのではなく、返値でエラーハンドリングできないかを考えました。

Rust の Result 型は以下の通りで、エラーが発生しうる処理においては、正常に処理された場合もエラーの場合も返値として返されます。そして正常時は Ok(T) 型、エラーの場合は Err(E) 型として返されます。(TE はジェネリクス。TypeScript と似てますね)

Result<T, E> is the type used for returning and propagating errors. It is an enum with the variants, Ok(T), representing success and containing a value, and Err(E), representing error and containing an error value.

enum Result<T, E> {
  Ok(T),
  Err(E),
}

Functions return Result whenever errors are expected and recoverable. In the std crate, Result is most prominently used for I/O. - std::result - Rust

上記 Rust の Result 型と同じようなことを TypeScript で実現してみたいと思います。

本記事の Result 型を使ったブラウザでのファイル読み込みの例を こちらの記事 で紹介しています。

throw, try catch の課題

まず Result 型を使いたいモチベーションとして TypeScript の throw, try catch によるエラーハンドリングの課題を挙げたいと思います。

関数がエラーをスローするかどうかがわからない

雑な例ですが、以下のような未成年の場合にエラーを投げるビールを入れる関数 servBeer() があったとします。

export const servBeer = (age: number) => {
  if (age < 20) throw new Error('未成年にお酒は提供できません');
  return '夕日スーパーウエット🍺';
}

この servBeer() 関数を別のモジュールで以下のように使用する場合、エラーハンドリングがされていなくてもリンタやコンパイラは何も言ってくれません。開発者もレビューアも servBeer() のソースコードを見ないとエラーハンドリングする必要があるのかどうかがわかりません。

import { servBeer } from './servBeer';
const beer = servBeer(18);

呼び出し元で servBeer() 関数の型を参照すると以下のようになっており、これを見ても servBeer() 関数がエラーをスローするのかどうかは判断できません。

(alias) servBeer(age: number): string

JSDoc には @throws タグがあり、エラーを投げる可能性があることを静的に知らせることは可能です。

The @throws tag allows you to document an error that a function might throw. You can include the @throws tag more than once in a single JSDoc comment.
@throws | Use JSDoc

試しに servBeer() 関数に JSDoc を書いてみます。

/**
 * @param {number} 年齢
 * @returns {string} ビール
 * @throws {Error} 未成年にお酒は提供できません
 */
export const servBeer = (age: number) => {
  if (age < 20) throw new Error('未成年にお酒は提供できません');
  return '夕日スーパーウエット';
}

呼び出し元で servBeer() 関数を参照すると以下のように表示され、エラーを投げることはわかります。

@param 年齢
@returns  ビール
@throws  {Error} 未成年にお酒は提供できません

ただ TypeScript と JSDoc の二重運用は冗長なのと、JSDoc はあくまでソフト面での対応なのでコンパイラで引っ掛けるといった強制的な対応がとれません。

catch する例外は unknown 型である

TypeScript では catch に渡される例外は unknown 型あるいは any 型です。tsconfig.jsoncompilerOptions.useUnknownInCatchVariable あるいは compilerOptions.stricttrue の場合 unknown 型、false の場合 any 型になります。

Use Unknown In Catch Variables - useUnknownInCatchVariables
In TypeScript 4.0, support was added to allow changing the type of the variable in a catch clause from any to unknown.
TypeScript: TSConfig リファレンス - すべてのTSConfigのオプションのドキュメント

JavaScript の throw は何でも投げつけられるかつ TypeScript は関数等で throw される例外の型まで推論してくれないので unknown または any 型になるのはまあ納得ではあります。

以下のいずれもが例外を発生させます。

throw "Error2"; // 文字列値である例外を生成します
throw 42; // 値 42 である例外を生成します
throw true; // 値 true である例外を生成します
throw new Error("Required"); // Required というメッセージを持ったエラーオブジェクトを生成します

throw - JavaScript | MDN

TypeScript で Result 型を作る

Result 型の定義

では TypeScript で Result 型を実装してみます。まず完成形を示します。

class _Result<T, E extends Error> {
  isOk (): this is Ok<T> {
    return this instanceof Ok;
  }

  isErr (): this is Err<E> {
    return this instanceof Err; 
  }
}

export class Ok<T> extends _Result<T, Error> {
  #value: T;
  
  constructor (value: T) {
    super();
    this.#value = value;
  }

  get value () {
    return this.#value;
  }
}

export class Err<E extends Error> extends _Result<unknown, E> {
  #error: E;

  constructor (error: E) {
    super();
    this.#error = error;
  }

  get error () {
    return this.#error;
  }
}

export type Result<T, E extends Error> = Ok<T> | Err<E>;

まず _Result クラスがあり、それを継承した Ok クラスと Err クラスがあります。 _Result クラスは isOk()isErr() メソッドを持っていて、返値の型が this is Ok<T>this is Err<E> となっています。これは TypeScript の this -based type guards で if 文等でこれらのメソッドを呼ぶことで型を限定することができます。

You can use this is Type in the return position for methods in classes and interfaces. When mixed with a type narrowing (e.g. if statements) the type of the target object would be narrowed to the specified Type.
TypeScript: Documentation - Classes

Ok クラスは value プロパティで正常時の返値を、Err クラスは error プロパティでエラー時の返値を保持します。プライベートプロパティーとゲッターを使用して読み取り専用にしています。

プライベートプロパティは、パブリックである通常のクラスプロパティ、例えばクラスフィールドやクラスメソッドなどに対するものです。プライベートプロパティはハッシュ # 接頭辞を使用して作成され、クラスの外部から合法的に参照することはできません。これらのクラスプロパティのプライバシーカプセル化は JavaScript 自身によって強制されます。 - プライベートプロパティ - JavaScript | MDN

TypeScript の readonly (TypeScript: Documentation - Classes) を使用してもよいのですが、Vanilla でできるものは極力 Vanilla で実装しています。

Result 型を使う

では Result 型を使って servBeer() を書き換えてみます。

import { Ok, Err } from "./result";

export const servBeer = (age: number) => {
  if (age < 20) return new Err(new Error('未成年にお酒は提供できません'));
  return new Ok('夕日スーパーウエット');
}

エラーを返したいときは Err クラスを使用し、正常時は Ok クラスに正常時の返値を入れて返します。

servBeer() 関数の型を厳密に書くと次のようにも書けます。

import { Result, Ok, Err } from "./result";

export const servBeer: (age: number) => Result<string, Error> = (age) => {
  if (age < 20) return new Err(new Error('未成年にお酒は提供できません'));
  return new Ok('夕日スーパーウエット');
}

次に servBeer() の呼び出し元です。

import { servBeer } from './servBeer';

const result = servBeer(18);

if (result.isErr()) {
  console.log(result.error.message);
} else {
  console.log(result.value);
}

注目すべきは、変数 resultservBeer(18) の返値を受け取った時点では

const result: Result<string, Error>

Result<string, Error> 型ですが、if (result.isErr())true と判定される if 文のスコープでは

const result: Err<Error>

Err 型に型の限定がされています。逆に else のスコープでは

const result: Ok<string>

Ok 型に型の限定がされています。

このように isOk() または isErr() で型を限定しないと Result 型から直接は値が取得できないため、エラーが発生しうる関数等において Result 型を返すようにしておけば、その関数の使用者にエラーハンドリングが必要であることを伝えることができます。

またエラーハンドリングをしていない場合、ぱっと見でわかるのでレビューアも気づきやすいです。

あとがき

TypeScript で Rust の Result 型のようなものを作ってみました。色々書きましたが、純粋に try catch より使いやすいと思います。エラーハンドリングは HTTP リクエストやデータベースアクセス等で使う機会が多いと思いますので、それらのケースでの使用例も別途紹介したいと思います。