関数型ドメインモデリング』勉強会 第10 章 実装:エラーの扱い(前編)

  • 2025.05.14
44
関数型ドメインモデリング』勉強会 第10 章 実装:エラーの扱い(前編)

当記事は、 関数型ドメインモデリング (達人出版会) を参考書として社内で行った勉強会の内容をまとめたものです。この記事の内容が気になった方は、ぜひ本書も参照いただければと思います。


10章の前半(10.1〜10.5)では、関数をパイプラインでつなげてワークフローを構築する際に、関数間の入出力の不一致をどう吸収するか、エラーをどうやってハンドリングするのかについてのテクニックについて説明します。

Result 型を使ってエラーを明示する

関数型プログラミングでは、物事をできるだけ明示的にすることを重要としていて、関数で発生するエラーについても明示的にします。

例えば以下のような、未検証の住所(UnvalidatedAddress)を入力とし、検証済の住所(CheckedAddress)を出力する関数(CheckAddressExists)を考えてみましょう。

type CheckAddressExists =
  UnvalidatedAddress -> CheckedAddress

これでは、どんなエラーが起こるかがわかりません。

そこで、Result型を使ってどんなエラーが起こるかを明確にします。

type CheckAddressExists =
  UnvalidatedAddress -> Result<CheckedAddress,AddressValidationError>

and AddressValidationError =
  | InvalidFormat of string
  | AddressNotFound of string

こうすることで、関数のシグネチャを見ただけでどんなエラーが起こるのかがわかり、ドキュメントとしても機能することができます。

ドメインエラーを扱う

ソフトウェアシステムは複雑であるため、起こりうる全てのエラーを処理することは困難です。そこでエラーを3つに分類し、整理をしやすくします。

  • ドメインエラー:ビジネスプロセスの一部として予想されるエラー
  • パニック:メモリ不足、ゼロによる除算、null参照など、システムを不明な状態にするエラー
  • インフラストラクチャエラー:ネットワークタイムアウト、認証失敗など、アーキテクチャの一部として予想されるエラー

型によるドメインエラーのモデリング

エラーに関しても、業務的なドメインモデルと同様にモデル化すべきです。

例えば、PlaceOrderError(注文確定エラー)を次のように選択型を使ってモデル化してみます。

type PlaceOrderError =
| ValidationError of string
| ProductOutOfStock of ProductCode
| RemoteServiceError of RemoteServiceError
...

選択型を使うことによって、エラーになりうる全てのケースに対して、コードが明示的なドキュメントとなります。
また、このエラーモデルに対するエラーの種類が増減しても、この選択型に対するパターンマッチをするコードがコンパイラーが警告を出すため、実装の不備を見逃すことがなくなります。

エラー処理はコードの見た目を悪くする

一般的にエラーを扱うためのコードは、条件分岐やtry/cactchブロックなどで煩雑になりがちです。
以降では、複雑さを回避したエラー処理について説明します。

Result を生成する関数の連鎖

次のような1入力1出力の3つの関数を考えてみましょう。

type FunctionA1 = Apple -> Bananas
type FunctionB1 = Bananas -> Cherryies
type FunctionC1 = Cherries -> Lemon

これらの関数は入出力の型が一致しているためA -> B -> Cと合成してパイプラインを生成できます。

let functionA : FunctionA1 = ...
let functionB : FunctionB1 = ...
let functionC : FunctionC1 = ...

let functionABC1 input = 
  input
  |> functionA
  |> functionB
  |> functionC

しかし、上記の3つの関数のシグネチャを見ただけでは、それぞれの関数でどのようなエラーが発生するかがわかりません。
そこで、各関数でどのようなエラーが発生するかを明確にするために、出力をResult型に変更してみます。

type FunctionA2 = Apple -> Result<Bananas, AppleError>
type FunctionB2 = Bananas -> Result<Cherryies, BananasError>
type FunctionC2 = Cherries -> Result<Lemon, CherriesError>

エラーは明確になりましたが、入出力の型が異なるため、合成をすることができなくなってしまいました。
これらを合成してパイプラインを生成するにはどうすれば良いでしょうか?

アダプターブロックの実装

先ほどのFunctionA2〜C2のような1つの入力に対してResult型のような選択型を出力する関数をスイッチ関数と呼びます。

スイッチ関数がResult型を入力にできれば3つの関数を合成してパイプラインを生成できます。そのために次のようなbind関数を定義します。

  • 入力はスイッチ関数とResult型
  • Resultが成功の場合は、その結果をスイッチ関数の入力とします。その結果出力はResult型となります。
  • Resultが失敗の場合は、失敗をそのまま出力します。
let bind = switchFn result =
  match result with
  | Ok success -> switchFn success
  | Error failuer -> Error failuer

このbind関数を使えばFunctionA2〜C2を合成してパイプラインを生成できます。

let functionABC2 input =
input
|> functionA2
|> bind functionB2
|> bind functionC2

なお、このbind関数はモナドの bind の例にもなっています。

共通のエラー型に変換する

実は先ほどのfunctionABC2には不備があり、本当は合成できません。

functionA2functionB2の部分に着目すると、

  1. functionA2が成功:Bananas型を出力
    1.1. functionB2が成功:Cherries型を出力
    1.2. functionB2が失敗:BannanasError型を出力
  2. functionA2が失敗:AppleError型を出力→これがそのままbind functionB2の出力となる

1.2の結果と2の結果を見てわかるように、functionA2が失敗した場合と、functionB2が失敗した場合とでエラーの型型が異なるために合成できないのです。

これを回避するために、AppleErrorBananasErrorCherriesErrorで構成される選択型をFruitError用意します。
また、関数での結果が失敗だった場合に、各失敗型を共通の失敗型FruitErrorに変換する関数fも用意します。
そして、以下のような結果が失敗だった場合に、失敗の型を共通の失敗型に変換するようなmapErrorを定義します。

let mapError f aResult =
  match aResult with
  | Ok success -> Ok success
  | Error failure -> Error (f failure)

このmapErrorを使って、functionA2〜C2がのエラー型をそろえます。

let functionA3 =
  input
  |> functionA2
  |> mapError (fun appleError -> AppleErrorCase appleError)

let functionB3 =
  input
  |> functionB2
  |> mapError (fun bananasError -> BananasErrorCase bananasError)

let functionC3 =
  input
  |> functionC2
  |> Result.mapError (fun cherriesError -> CherriesErrorCase cherriesError)

このA3〜B3を使うことで、ようやくパイプラインを生成することができるようになりました。

その他のアダプター

本書では、紹介した以外に、1入力1出力の関数、例外の処理、行き止まりの関数の処理などに対するアダプターも紹介されています。