🛤️

TypeScriptで始めるRailway Oriented Programming

2024/04/03に公開

はじめに

今ではRailway Oriented Programming(ROP)は広く知られたテクニックかもしれないが日本語の記事を見ているとResult型の紹介はあるものROPには言及しておらず、現実的なプログラミングテクニックとして落とし込んでいるような記事が少ないことに気づき、筆者にとって身近な言語であるTypeScriptを題材に気軽に入門するための記事を書くことにした。

ROPについての詳細な解説についてはRailway Oriented Programming | F# for fun and profitやその関連リンクを辿って欲しい。

本稿ではあくまで実装例を用いて概観を把握できるような内容に留める。
実装方法についてはライブラリを使用してもっとシンプルに記述することが出来るが、ROPやその背後にある関数型プログラミングのメンタルモデルもある程度イメージできるようにするためにあえて愚直な実装方法を採用する。

また、ROPはあらゆる場面で有効なテクニックではなく明確に向いていないケースが存在することはあらかじめ断っておきたい。
詳細はAgainst Railway-Oriented Programming | F# for fun and profitを参照して欲しい。

Railway Oriented Programmingとは

関数型プログラミングの文脈で有用なプログラミング手法で、
端的に表現するとEither型のLeftにカスタムエラーを集約させるような手法といえる。

Any Haskellers reading this will immediately recognize this approach as the Either type, specialized to use a list of a custom error type for the Left case. In Haskell, something like: type TwoTrack a b = Either [a] (b,[a]) - Railway Oriented Programming | F# for fun and profit

誤解を恐れずさらに簡単に言ってしまうと、処理の成否を判定し分岐を行うコンテナのようなものを用意し、メインのロジックに集中させるテクニックだと思ってもらって良い。

前提として関数型プログラミングの基本的な指向は「値の計算の合成」によってプログラムを構築することであり、HaskellやF#などの一般に関数型言語と呼ばれるような言語ではモナドやパターンマッチ、パイプ等の言語機能によって「値の計算の合成」以外の要素を分離しより数式的な記述方法を提供することでこれを実現し易くしている。という認識を持つと良さそうだ。

前述のEither型もモナドの一種だがROPではモナドのような抽象概念ではなくエラーハンドリングというより具体的な問題の解決に焦点を当てており、本稿においてもモナドなどの説明はしない。

ROPを用いた処理フローのイメージは以下の画像の様になる。
Result<Error, Data>というインターフェースについては次のResult型で説明する。

本稿を開いたようなプログラマー諸氏には文面で説明するよりコードを見てもらった方が理解が早いであろうと思うので、以降は実際の実装例を用いて説明を進める。
ROPを実践する際の実装例は色々考えられるため、興味のある読者は是非自身でも考えてみて欲しい。

今回は以下の様なサンプルコードをROPを用いて改善する。
サンプルのためコード量が少なくそれほど問題があるように見えないかもしれないが、個々のロジックは切り出され関数化されているもののtry..catchやif文による分岐が露出しており処理が増えるとコードが追いづらくなることが予想できる。

  // isValid**: 受け取った有効かどうかを判定する
  // throwable**: 例外を投げる可能性のある処理
  // convert**: 何らかの値を受け取って変換した値を返す
  // response**: プログラムとしての出力を行う(IO系)
  try {
    if (!isValidParam(anyParameter)) {
      responseError(new Error("Invalid parameter"))
      return
    }

    const data1 = throwable1(anyParameter)
    const converted1 = convertData1(data1)
    const data2 = throwable2(converted1)
    const converted2 = convertData2(data2)

    if (!isValidData(converted2)) {
      responseError(new Error("converted2 is negative"))
      return
    }

    responseOk(converted2)
  } catch (error) {
    if (error instanceof Error) {
      const convertedError = convertErrorMessage(error)
      responseError(convertedError)
      return
    }
    responseError(new Error("Unknown error"))
  }

Result型

ROPでは以下のような型を用いる。
errorの型についてはカスタムエラークラスを使用する等プロジェクト内で制約を設けると良いだろう。

type Success<D> = {
  isSuccess: true
  data: D
}

type Failure<E> = {
  isSuccess: false
  error: E
}

type Result<E, D> = Failure<E> | Success<D>

ポイントとしてはisSuccessという共通のプロパティにリテラル型を指定したオブジェクト同士のユニオン型になっているため、isSuccessを用いて型ガードすることで判別可能な形になっている点だ。
この辺りの実装は使用する言語によって多少異なってくるだろう。
参考: https://typescriptbook.jp/reference/values-types-variables/discriminated-union

加えてResult型を生成する関数を用意しておくと良い。
以下はerrordataの型をペアで定義できるように実装したパターンだ。

const createResult = <E, D>() => {
  const createSuccess = (data: D): Success<D> => ({
    isSuccess: true,
    data,
  })

  const createFailure = (error: E): Failure<E> => ({
    isSuccess: false,
    error,
  })

  return {
    createSuccess,
    createFailure,
  }
}

try..catchからResultへ

次に正常系と異常系の分岐をResult型を使った実装に統一するための関数を用意する。
throwableな関数を受けとってResultとして返す。throwされた場合は内部でcatchしてFailureとして返すことで例外をcatchしつつ宣言的に記述出来るようにしたいという意図だ。

尚、カリー化してあるのはこの後登場するflatMap関数に適用するためだ。

const execSafety =
  <Data, Args extends unknown[]>(fn: (...args: Args) => Data) =>
  (...args: Args): Result<Error, Data> => {
    const { createFailure, createSuccess } = createResult<Error, Data>()

    try {
      const data = fn(...args)
      return createSuccess(data)
    } catch (error) {
      if (error instanceof Error) {
        return createFailure(error)
      }
      return createFailure(new Error("Unknown error occurred"))
    }
  }

バリデーションをResultへ

プログラム的な例外のハンドリング以外にもvalidation等の結果によって条件を分岐するようなケースもResultでハンドリングすることでコードの一貫性を保てる。

const validateR =
  <E, D>(validator: (param: D) => boolean, error: E) =>
  (data: D): Result<E, D> => {
    const { createSuccess, createFailure } = createResult<E, D>()
    if (validator(data)) {
      return createSuccess(data)
    }
    return createFailure(error)
  }

map関数

純粋なロジックをResult型でラップするための関数群を定義する。

interface

この関数を通す際のResultの形はResult<E1, D1> -> Result<E2, D2>のようになり。
E1 -> E2, D1 -> D2の部分のロジックをそれぞれコールバック関数として受け取る形だ。

ちなみに"map"は配列操作のArray.prototype.mapメソッドと同じで"写像"から採っているが、とりあえず配列のものと同じような動作をするResult版のmapだと考えてよい。

実装例

// Result<E1, D1> -> Result<E2, D2>
const mapResult = <E1, D1, E2, D2>(
  result: Result<E1, D1>,
  transformFailure: (error: E1) => E2,
  transformSuccess: (data: D1) => D2,
): Result<E2, D2> => {
  const { createSuccess, createFailure } = createResult<E2, D2>()
  return result.isSuccess
    ? createSuccess(transformSuccess(result.data))
    : createFailure(transformFailure(result.error))
}

このままでは常に正常系、異常系を渡す必要があるため「前の処理が成功・失敗していた時だけ」実行できるようなものも作っておくと便利だ。

// Result<E, D1> -> Result<E, D2>
const mapSuccess = <E, D1, D2>(
  result: Result<E, D1>,
  fn: (data: D1) => D2,
): Result<E, D2> => {
  const { createSuccess } = createResult<E, D2>()
  return result.isSuccess ? createSuccess(fn(result.data)) : result
}

// Result<E1, D> -> Result<E2, D>
const mapFailure = <E1, D, E2>(
  result: Result<E1, D>,
  fn: (error: E1) => E2,
): Result<E2, D> => {
  const { createFailure } = createResult<E2, D>()
  return result.isSuccess ? result : createFailure(fn(result.error))
}

flatMap関数

map関数では E1 -> E2, D1 -> D2 の部分に「Result型を返す関数」を使用できない。
例えば何らかの値を受け取ってthrowableな処理を実行するような場合、以下の様に記述したくなるだろう。

const throwableResult1 = execSafety(throwable1)
const convertedResult1 = mapSuccess(throwableResult1, convertData1)
const throwableResult2 = mapSuccess(convertedResult1, execSafety(throwable2))
// ↓ここでtype errorになる
const convertedResult2 = mapSuccess(throwableResult2, convertData2)

しかしこの状態ではthrowableResult2Result<Result<E2, D2>, Result<E2, D2>の様にResultがネストしてしまいResultのレールを継続出来なくなってしまう。

これに対応するためにflatMap関数を作成する。

interface

この関数を通す際のResultの形はmapの時と変わらずResult<E1, D1> -> Result<E2, D2>のようになる。
異なる点としては「値を変換した結果をResultにして返す関数」を受け取って「ネストされたResultをflatにして返す」という点だ。
単にmapに「Resultを返す関数」を渡せるようにしたものだと言い換えても良い。
変換関数としてE1 -> Result<E1, D>, D1 -> Result<E, D2> のような関数を受け取る。
大雑把に言うとArray.prototype.flatMapと大体似たようなものだ。

実装例

map同様に fMapSuccess , fMapFailure も作ると良いだろう。

const fMapResult = <E1, D1, E2, D2>(
  result: Result<E1, D1>,
  transformFailure: (error: E1) => Result<E2, D2>,
  transformSuccess: (data: D1) => Result<E2, D2>,
): Result<E2, D2> =>
  result.isSuccess
    ? transformSuccess(result.data)
    : transformFailure(result.error)

副作用の実行

最後に副作用を分離するためのモジュールを作成する。

関数型プログラミングの文脈では「参照透過性のない処理」のことを副作用と呼ぶ。
引数によってプログラム上で結果が一意に定まらない様な乱数の生成やファイル読み込みなども副作用を含むと表現するが、今回は「値を返さない処理」についてのハンドリングを実装する。

今までは純粋な「値を受け取って値を返す」関数をResultのレール上で実行していくためのモジュールを作ってきたがプログラムが現実世界に影響を与えるには「APIとしてのレスポンスを返す」「ログを出力する」などの値を返さない処理が必ず必要になる。
これらの処理を値の計算のつながりであるResultのレールから明示的に分離するのだ。

const performEffect = <E, D>(
  result: Result<E, D>,
  performSuccess: (data: D) => void,
  performFailure: (error: E) => void,
): void => {
  if (result.isSuccess) {
    performSuccess(result.data)
    return
  }
  performFailure(result.error)
}

これもmapなどと同様に成功系・失敗系を作っておくと良いだろう。

最終的な実装例

ここまで作ってきた関数群を利用して、冒頭で紹介したコードを書き換えると以下の様になる。

  const validateParamResult = validateR(isValidParam, validationError)(anyParameter)
  const throwableResult1 = fMapSuccess(validateParamResult, execSafety(throwable1))
  const convertedResult1 = mapSuccess(throwableResult1, convertData1)
  const throwableResult2 = fMapSuccess(convertedResult1, execSafety(throwable2))
  const convertedResult2 = mapSuccess(throwableResult2, convertData2)
  const validateDataResult = fMapSuccess(convertedResult2, validateR(isValidData, validationError))

  const messageConvertedResult = mapFailure(validateDataResult, convertErrorMessage)

  performEffect(messageConvertedResult, responseOk, responseError)

最後に

ROPの実践によって例外のハンドリングや条件分岐が隠蔽されたことで、幾分か認知負荷が下がったのではないだろうか。

尚、この最終的な実装を見て「あまり良くないな」と感じた方は冒頭にも記載したAgainst Railway-Oriented Programming | F# for fun and profitに目を通して欲しい。
もしかするとそこにその感覚の理由があるかもしれない。

もし単純に読みづらさを感じたのであれば、それは私の実装の問題であろう。
実際のところ本格的にTypeScriptでROPを実施するには今回の実装だけでは足りず、pipeの導入をはじめ、Promiseの取り扱い(async/awaitやthen/catchとの共存)など改善の余地は多い。

fp-tsremedaのようなライブラリの導入を検討しても良いかもしれない。ROPの文脈ではpipeを使用するだけでも十分恩恵があるだろう。


参考: Railway Oriented Programming | F# for fun and profit

Discussion