第4章: データバリデーション¶
はじめに¶
F# では、判別共用体、スマートコンストラクタ、Result 型を組み合わせることで、コンパイル時と実行時の両方でデータの整合性を保証できます。本章では、Result、Validated パターン、ドメインプリミティブを使ったデータバリデーションの実現方法を学びます。
1. 基本的なバリデーション(Result を使用)¶
Result によるバリデーション¶
Result<'T, 'E> は、成功時に Ok、失敗時に Error を返す型です。バリデーションに最適です。
/// バリデーション結果の型エイリアス
type ValidationResult<'T> = Result<'T, string list>
/// 名前のバリデーション
let validateName (name: string) : ValidationResult<string> =
if System.String.IsNullOrEmpty(name) then
Error ["名前は空にできません"]
elif name.Length > 100 then
Error ["名前は100文字以内である必要があります"]
else
Ok name
/// 年齢のバリデーション
let validateAge (age: int) : ValidationResult<int> =
if age < 0 then
Error ["年齢は0以上である必要があります"]
elif age > 150 then
Error ["年齢は150以下である必要があります"]
else
Ok age
/// メールアドレスのバリデーション
let validateEmail (email: string) : ValidationResult<string> =
let emailRegex = System.Text.RegularExpressions.Regex(@".+@.+\..+")
if emailRegex.IsMatch(email) then Ok email
else Error ["無効なメールアドレス形式です"]
// 使用例
validateName "田中太郎" // => Ok "田中太郎"
validateName "" // => Error ["名前は空にできません"]
validateAge 25 // => Ok 25
validateAge -1 // => Error ["年齢は0以上である必要があります"]
2. 列挙型とスマートコンストラクタ¶
判別共用体による列挙型¶
/// 会員種別
type Membership =
| Bronze
| Silver
| Gold
| Platinum
module Membership =
let fromString (s: string) : ValidationResult<Membership> =
match s.ToLower() with
| "bronze" -> Ok Bronze
| "silver" -> Ok Silver
| "gold" -> Ok Gold
| "platinum" -> Ok Platinum
| _ -> Error [sprintf "無効な会員種別: %s" s]
let toString = function
| Bronze -> "bronze"
| Silver -> "silver"
| Gold -> "gold"
| Platinum -> "platinum"
/// ステータス
type Status =
| Active
| Inactive
| Suspended
// 使用例
Membership.fromString "gold" // => Ok Gold
Membership.fromString "GOLD" // => Ok Gold(大文字小文字を区別しない)
Membership.fromString "invalid" // => Error ["無効な会員種別: invalid"]
3. Validated パターン(エラー蓄積)¶
Result は最初のエラーで停止しますが、すべてのエラーを収集したい場合は Validated パターンを使用します。
/// Validated 型(エラー蓄積対応)
type Validated<'E, 'A> =
| Valid of 'A
| Invalid of 'E list
module Validated =
let valid a : Validated<'E, 'A> = Valid a
let invalid errors : Validated<'E, 'A> = Invalid errors
let map (f: 'A -> 'B) (va: Validated<'E, 'A>) : Validated<'E, 'B> =
match va with
| Valid a -> Valid (f a)
| Invalid errs -> Invalid errs
let isValid = function
| Valid _ -> true
| Invalid _ -> false
let toResult = function
| Valid a -> Ok a
| Invalid errs -> Error errs
/// 2つの Validated を結合(エラー蓄積)
let combine (f: 'A -> 'B -> 'C) (va: Validated<'E, 'A>) (vb: Validated<'E, 'B>) : Validated<'E, 'C> =
match va, vb with
| Valid a, Valid b -> Valid (f a b)
| Invalid e1, Invalid e2 -> Invalid (e1 @ e2) // エラーを蓄積!
| Invalid e, _ -> Invalid e
| _, Invalid e -> Invalid e
使用例¶
let result =
Validated.combine (+)
(Validated.invalid ["error1"])
(Validated.invalid ["error2"])
// => Invalid ["error1"; "error2"] // 両方のエラーが収集される
4. ドメインプリミティブ(バリデーション付き型)¶
プライベートなコンストラクタを持つ型で、不正な値の生成を防ぎます。
/// 商品ID
type ProductId = private ProductId of string
module ProductId =
let private pattern = System.Text.RegularExpressions.Regex(@"^PROD-\d{5}$")
let create (id: string) : Validated<string, ProductId> =
if pattern.IsMatch(id) then Validated.valid (ProductId id)
else Validated.invalid [sprintf "無効な商品ID形式: %s (PROD-XXXXXの形式が必要)" id]
let value (ProductId id) = id
let unsafe id = ProductId id // テスト用
/// 価格(正の数)
type Price = private Price of decimal
module Price =
let create (amount: decimal) : Validated<string, Price> =
if amount <= 0m then Validated.invalid ["価格は正の数である必要があります"]
else Validated.valid (Price amount)
let value (Price p) = p
let unsafe amount = Price amount
/// 数量(正の整数)
type Quantity = private Quantity of int
module Quantity =
let create (qty: int) : Validated<string, Quantity> =
if qty <= 0 then Validated.invalid ["数量は正の整数である必要があります"]
else Validated.valid (Quantity qty)
let value (Quantity q) = q
let unsafe qty = Quantity qty
// 使用例
ProductId.create "PROD-00001" // => Valid (ProductId "PROD-00001")
ProductId.create "INVALID" // => Invalid ["無効な商品ID形式: INVALID..."]
Price.create 1000m // => Valid (Price 1000m)
Price.create 0m // => Invalid ["価格は正の数である必要があります"]
5. 商品モデル¶
ドメインプリミティブを使用した商品型¶
/// 商品
type Product =
{ Id: ProductId
Name: ProductName
Price: Price
Description: string option
Category: string option }
module Product =
let create
(id: string)
(name: string)
(price: decimal)
(description: string option)
(category: string option)
: Validated<string, Product> =
Validated.combine3
(fun pid pname pprice ->
{ Id = pid
Name = pname
Price = pprice
Description = description
Category = category })
(ProductId.create id)
(ProductName.create name)
(Price.create price)
// 使用例
Product.create "PROD-00001" "テスト商品" 1000m None None
// => Valid { Id = ProductId "PROD-00001"; Name = ProductName "テスト商品"; Price = Price 1000m; ... }
Product.create "INVALID" "" -100m None None
// => Invalid ["無効な商品ID形式...", "商品名は空にできません", "価格は正の数である必要があります"]
// すべてのエラーが蓄積される!
6. 注文ドメインモデル¶
/// 注文ID
type OrderId = private OrderId of string
module OrderId =
let private pattern = System.Text.RegularExpressions.Regex(@"^ORD-\d{8}$")
let create (id: string) : Validated<string, OrderId> =
if pattern.IsMatch(id) then Validated.valid (OrderId id)
else Validated.invalid [sprintf "無効な注文ID形式: %s" id]
/// 注文アイテム
type OrderItem =
{ ProductId: ProductId
Quantity: Quantity
Price: Price }
module OrderItem =
let create (productId: string) (quantity: int) (price: decimal) : Validated<string, OrderItem> =
Validated.combine3
(fun pid qty pr -> { ProductId = pid; Quantity = qty; Price = pr })
(ProductId.create productId)
(Quantity.create quantity)
(Price.create price)
let total (item: OrderItem) =
Price.value item.Price * decimal (Quantity.value item.Quantity)
/// 注文
type Order =
{ OrderId: OrderId
CustomerId: CustomerId
Items: OrderItem list
OrderDate: System.DateTime
Total: decimal option
Status: Status option }
module Order =
let calculateTotal (order: Order) =
order.Items |> List.sumBy OrderItem.total
7. 条件付きバリデーション(ADT)¶
データの種類に応じて異なるバリデーションルールを適用する場合は、判別共用体を使用します。
/// 通知(判別共用体による条件付きバリデーション)
type Notification =
| EmailNotification of to': string * subject: string * body: string
| SMSNotification of phoneNumber: string * body: string
| PushNotification of deviceToken: string * body: string
module Notification =
let private emailPattern = System.Text.RegularExpressions.Regex(@".+@.+\..+")
let private phonePattern = System.Text.RegularExpressions.Regex(@"\d{2,4}-\d{2,4}-\d{4}")
let createEmail (to': string) (subject: string) (body: string) : Validated<string, Notification> =
let errors = [
if not (emailPattern.IsMatch(to')) then "無効なメールアドレス形式です"
if System.String.IsNullOrEmpty(subject) then "件名は空にできません"
if System.String.IsNullOrEmpty(body) then "本文は空にできません"
]
if errors.IsEmpty then Validated.valid (EmailNotification(to', subject, body))
else Validated.invalid errors
let createSMS (phoneNumber: string) (body: string) : Validated<string, Notification> =
let errors = [
if not (phonePattern.IsMatch(phoneNumber)) then "無効な電話番号形式です"
if System.String.IsNullOrEmpty(body) then "本文は空にできません"
]
if errors.IsEmpty then Validated.valid (SMSNotification(phoneNumber, body))
else Validated.invalid errors
let createPush (deviceToken: string) (body: string) : Validated<string, Notification> =
let errors = [
if System.String.IsNullOrEmpty(deviceToken) then "デバイストークンは空にできません"
if System.String.IsNullOrEmpty(body) then "本文は空にできません"
]
if errors.IsEmpty then Validated.valid (PushNotification(deviceToken, body))
else Validated.invalid errors
// 使用例
Notification.createEmail "test@example.com" "テスト" "本文"
// => Valid (EmailNotification ("test@example.com", "テスト", "本文"))
Notification.createSMS "090-1234-5678" "本文"
// => Valid (SMSNotification ("090-1234-5678", "本文"))
8. バリデーションユーティリティ¶
バリデーション結果の構造化¶
/// バリデーションレスポンス
type ValidationResponse<'T> =
{ Valid: bool
Data: 'T option
Errors: string list }
/// Person のバリデーション
let validatePerson (name: string) (age: int) (email: string option) : ValidationResponse<Person> =
let nameV =
if System.String.IsNullOrEmpty(name) then Validated.invalid ["名前は空にできません"]
else Validated.valid name
let ageV =
if age < 0 then Validated.invalid ["年齢は0以上である必要があります"]
else Validated.valid age
let validated =
Validated.combine
(fun n a -> { Name = n; Age = a; Email = email })
nameV
ageV
match validated with
| Valid p -> { Valid = true; Data = Some p; Errors = [] }
| Invalid e -> { Valid = false; Data = None; Errors = e }
// 使用例
validatePerson "田中" 30 None
// => { Valid = true; Data = Some { Name = "田中"; Age = 30; Email = None }; Errors = [] }
validatePerson "" -1 None
// => { Valid = false; Data = None; Errors = ["名前は空にできません"; "年齢は0以上である必要があります"] }
例外をスローするバリデーション¶
let conformOrThrow (validated: Validated<string, 'T>) : 'T =
match validated with
| Valid a -> a
| Invalid errs -> failwithf "Validation failed: %s" (System.String.Join(", ", errs))
9. 計算関数¶
/// アイテム合計を計算
let calculateItemTotal (item: OrderItem) =
Price.value item.Price * decimal (Quantity.value item.Quantity)
/// 注文合計を計算
let calculateOrderTotal (order: Order) =
order.Items |> List.sumBy calculateItemTotal
/// 割引を適用
let applyDiscount (total: decimal) (discountRate: float) : Result<decimal, string> =
if discountRate < 0.0 || discountRate > 1.0 then
Error "割引率は0から1の間である必要があります"
else
Ok (total * (1m - decimal discountRate))
/// 複数の価格を合計
let sumPrices (prices: decimal list) : decimal =
prices |> List.sum
Clojure Spec / Scala との比較¶
| 特徴 | Clojure Spec | Scala | F# |
|---|---|---|---|
| 検証タイミング | 実行時 | コンパイル時 + 実行時 | コンパイル時 + 実行時 |
| エラー蓄積 | explain-data | Validated パターン | Validated パターン |
| 型安全性 | 動的 | 静的(Opaque Type) | 静的(プライベートコンストラクタ) |
| ジェネレータ | 組み込み | ScalaCheck | FsCheck |
| 関数仕様 | fdef | 型シグネチャ | 型シグネチャ |
まとめ¶
本章では、F# におけるデータバリデーションについて学びました:
- Result: 基本的な成功/失敗の表現
- Validated パターン: エラーの蓄積
- ドメインプリミティブ: プライベートコンストラクタによる型安全なドメイン型
- スマートコンストラクタ: バリデーション付きのインスタンス生成
- 判別共用体: 条件付きバリデーション
- ValidationResponse: 構造化されたバリデーション結果
F# の型システムを活用することで、コンパイル時に多くのエラーを検出し、実行時には Validated パターンで詳細なエラー情報を提供できます。
参考コード¶
本章のコード例は以下のファイルで確認できます:
- ソースコード:
apps/fsharp/part2/src/Library.fs - テストコード:
apps/fsharp/part2/tests/Tests.fs
次章予告¶
次章では、プロパティベーステストについて学びます。FsCheck を使った生成的テストの手法を探ります。