恐竜本舗

エンジニアをしている恐竜の徒然日記です。

TypeScript の例外処理を Railway Oriented Programming(Result 型)で楽にしたい

はじめに

TypeScript で Result 型を使うと、例外処理の扱いで良いぞ!という話をよく耳にします。

TypeScriptの「Result型」のすゝめ - Speaker Deck

zenn.dev

zenn.dev

例外処理が多いビジネスロジックだと、手続き型プログラミングでなかなか辛みを感じるので、こちらを試してみます。

Railway Oriented Programmingとは

Railway Oriented Programmingとは、2014年にScott Wlaschinさんによって提唱された、エラーハンドリングの取り扱いに焦点を当てたプログラミング手法です。

fsharpforfunandprofit.com

関数型プログラミングの文脈で有用なプログラミング手法で、プログラミングを成功ケースと失敗ケースの分岐がある、線路に見立てます。

Railwy Oriented Programming

ref: https://proandroiddev.com/railway-oriented-programming-in-kotlin-f1bceed399e5

関数型プログラミングの中で、

1つ1つのビジネスロジックを関数として分解し、次の関数に繋げていくと、成功と失敗のケースが出てきます。

この2つの分岐に対して、失敗を throw するのではなく、次の関数の入力値を 成功 / 失敗 どちらでも受け取るようにしてそのまま繋げ、エラー情報を伝播させていくという手法となってます。

手続き型の課題

手続き型プログラミングだと、各処理の中で throw Error を投げて、利用側で try catch するなどします。 メールアドレスのバリデーション例で考えてみます。

function validateLength(input: string): string {
  if (input.length < 5) {
    throw new Error("Input must be at least 5 characters long");
  }
  return input;
}

function validateEmail(input: string): string {
  if (!input.includes("@")) {
    throw new Error("Input must be a valid email address");
  }
  return input;
}

function extractDomain(input: string): string {
    const atIndex = input.indexOf("@");
    if (atIndex === -1) {
        throw new Error("Unexpected Error: @ not found");
    }
  return input.substring(atIndex + 1);
}

function processInput(input: string): string {
  try {
    const validatedLength = validateLength(input);
    const validatedEmail = validateEmail(validatedLength);
    const domain = extractDomain(validatedEmail);
    return domain;
  } catch (error) {
    if (error instanceof Error) {
       console.error(`[Error] ${error.message}`);
    } else {
       console.error("[Error] Unknown Error");
    }
    return null;
  }
}

console.log(processInput("short@")); // Output: [Error] Input must be a valid email address

このように、バリデーション処理などのビジネスロジックを関数で分けた際、

各関数内でif 分岐をして throw Error を投げ、catch の中でエラーハンドリングをすることになります。

これには、下記のような課題があります。

  • ネストの増加と可読性の低下

エラー処理が入れ子になりやすく、入れ子が多量に発生してくると処理の流れを追いにくくなります

  • エラーハンドリングミス

各処理の中で適切にエラーハンドリングすることが求められるため、考慮漏れが発生しやすく、 try catch で括れていないか適宜確認したり、各所でのエラーハンドリングの仕方が統一されずにログ処理や返り値がバラバラなど、ルールの統一も難しいです

  • 例外の型が伝播されない

TypeScript で実装しても、try catch の catch 節に入ってくるerror は unknown 型として受け取ります。

そのため、 error instanceof Error の際とそれ以外で エラー分岐を行うますが、どの箇所でエラーが発生したのか、エラーの型を受け取らないため、エラーの補足箇所の判断が難しい場合があります。

こうした問題を解決しようというのが、Railway Oriented Programming の考え方です。

Railway Oriented Programming (Result 型)で書いてみる

Result 型とは

Result型とは、Railway Oriented Programming(以下、ROP)の設計で使われる型になります。 成功と失敗の双方の可能性を考慮した型となっています。

言語ごとに呼び名が違い、Scala ではEither 型と呼ばれていたりします。

TypeScript で簡易的に示すと、下記のようになります。

type Success<T> = { type: "success"; value: T };
type Failure<E> = { type: "failure"; error: E };

type Result<T, E> = Success<T> | Failure<E>;

この型を用いて、上記の手続き型を直してみます。

ROP に修正する

export const validateLength = (name: string): Result<string, Error> => {
  if (name.length > 3) {
    return { type: "success", value: name };
  }
  return { type: "failure", error: new Error("Name too short") };
};

export const validateEmail = (email: string): Result<string, Error> => {
  const atIndex = email.indexOf("@");
  if (atIndex > 0 && atIndex < email.length - 1) {
    return { type: "success", value: email };
  }
  return { type: "failure", error: new Error("Invalid email") };
};

export const extractDomain = (email: string): Result<string, Error> => {
  const atIndex = email.indexOf("@");
  if (atIndex !== -1) {
    return { type: "success", value: email.slice(atIndex + 1) };
  }
  return { type: "failure", error: new Error("Invalid email format") };
};

上記のように、 throw を辞めて、Result 型で成功時、失敗時の返却を行います。 型で縛っているので、エラーの返却漏れを防止できます。

次に、次の関数にエラー情報を伝播させるため、下記の2つを実装します。

  • Result型を受け取れる関数(Scala では flatMap という名前が使われてるのでそれに揃えます)
    • 入力として、手前の関数の結果が成功でも、失敗でも受け取る
    • 受け取った結果が成功であれば、次の関数を実行
    • 受け取った結果が失敗であれば、何もせず返却(エラー情報をそのまま伝播する)
  • 関数を繋ぐための関数(pipe)
    • 上記の flatMap の結果を次に渡すための関数
function flatMap<T, E, U, F>(
  f: (x: T) => Result<U, F>,
): (result: Result<T, E>) => Result<U, E | F> {
  return (result) => {
    if (result.type === "success") {
      return f(result.value);
    }
    return result;
  };
}

function pipe<T>(...fns: Array<(arg: T) => T>): (arg: T) => T {
  return (arg: T) => fns.reduce((result, fn) => fn(result), arg);
}

実行処理も書き換えましょう。

手続き型では、try catch のtry 節の中で、すべての処理を実行しましたが、Result型を使う場合これらをひとまとめにできます。

const result = (input: string) =>
  pipe(
    flatMap(validateLength),
    flatMap(validateEmail),
    flatMap(extractDomain),
  )({ type: "success", value: input });

const printResult = (input: string) => {
  const res = result(input);
  if (res.type === "success") {
    console.log(`[Success] Domain: ${res.value}`);
  } else {
    console.error(`[Error] ${(res.error as Error).message || "Unknown error"}`);
  }
};

printResult("success@example.com"); // [Success] Domain: example.com
printResult("short@"); // [Error] Invalid email
printResult("no"); //[Error] Name too short

Result 型を用いると、エラー情報が伝播されるため、実行処理printResult の中で発生したエラーの型を受け取ることができます。

(例は単に new Error しているだけですが、 errorStatus などを渡してあげれば、より細かく判別しやすくなります)

まとめ

Result型を用いることで、下記のような恩恵を受けやすくなることがわかりました。

  • エラー処理の入れ子が減り、処理の追いかけがしやすくなる
  • 型安全にエラーハンドリングを行え、考慮漏れミスをなくせる
  • エラー情報の型を伝播させることで、エラーハンドリングがより容易になる

※ Result型と併用して、 ts-pattern を用いてパターンマッチングさせると、エラーの扱いがよりしやすくなるようです。

buildersbox.corp-sansan.com

ROPの手法はエラーハンドリングが煩雑にならず、型安全に漏れの防止ができるため、上手く取り入れていきたいです。

Reference

今回の記事作成にあたり、下記を参考にさせて頂きました。