Skip to content

第4章: データバリデーション

はじめに

Haskell では型システムと組み合わせて、コンパイル時と実行時の両方でデータの整合性を保証できます。本章では、EitherValidated パターン、スマートコンストラクタを使ったデータバリデーションの手法を学びます。

1. 基本的なバリデーション(Either を使用)

Either によるバリデーション

Either a b は失敗時に Left、成功時に Right を返す型です:

-- | バリデーションエラー型
data ValidationError
    = EmptyValue String
    | TooShort String Int Int  -- field, actual, minimum
    | TooLong String Int Int   -- field, actual, maximum
    | OutOfRange String Int Int Int
    | InvalidFormat String String
    deriving (Show, Eq)

-- | バリデーション結果
type ValidationResult a = Either ValidationError a

-- | 名前のバリデーション
validateNonEmpty :: String -> String -> ValidationResult String
validateNonEmpty field value
    | null value = Left (EmptyValue field)
    | otherwise = Right value

-- | 年齢のバリデーション
validateRange :: String -> Int -> Int -> Int -> ValidationResult Int
validateRange field minVal maxVal value
    | value < minVal || value > maxVal = Left (OutOfRange field value minVal maxVal)
    | otherwise = Right value

-- 使用例
validateNonEmpty "name" "田中"  -- => Right "田中"
validateNonEmpty "name" ""      -- => Left (EmptyValue "name")
validateRange "age" 0 150 25    -- => Right 25
validateRange "age" 0 150 (-1)  -- => Left (OutOfRange "age" (-1) 0 150)

2. スマートコンストラクタ

ドメインプリミティブ

newtypes とスマートコンストラクタを組み合わせて、不正な値を型レベルで防ぎます:

-- | Name ドメイン型
newtype Name = Name { unName :: String }
    deriving (Show, Eq)

-- | スマートコンストラクタ
mkName :: String -> ValidationResult Name
mkName value = do
    _ <- validateNonEmpty "name" value
    _ <- validateMaxLength "name" 100 value
    Right (Name value)

-- | Age ドメイン型
newtype Age = Age { unAge :: Int }
    deriving (Show, Eq)

mkAge :: Int -> ValidationResult Age
mkAge value = do
    _ <- validateRange "age" 0 150 value
    Right (Age value)

-- | Email ドメイン型
newtype Email = Email { unEmail :: String }
    deriving (Show, Eq)

mkEmail :: String -> ValidationResult Email
mkEmail value = do
    _ <- validateNonEmpty "email" value
    _ <- validateEmail value
    Right (Email value)

-- 使用例
mkName "田中太郎"  -- => Right (Name "田中太郎")
mkName ""          -- => Left (EmptyValue "name")
mkAge 25           -- => Right (Age 25)
mkAge 200          -- => Left (OutOfRange "age" 200 0 150)

3. Validated パターン(エラー蓄積)

Either は最初のエラーで停止しますが、すべてのエラーを収集したい場合は Validated パターンを使用:

-- | Validated 型
data Validated a
    = Valid a
    | Invalid [ValidationError]
    deriving (Show, Eq)

instance Functor Validated where
    fmap f (Valid a) = Valid (f a)
    fmap _ (Invalid es) = Invalid es

instance Applicative Validated where
    pure = Valid
    Valid f <*> Valid a = Valid (f a)
    Invalid es1 <*> Invalid es2 = Invalid (es1 ++ es2)  -- エラーを蓄積
    Invalid es <*> _ = Invalid es
    _ <*> Invalid es = Invalid es

-- | Either から Validated への変換
fromResult :: ValidationResult a -> Validated a
fromResult (Right a) = Valid a
fromResult (Left e) = Invalid [e]

-- 使用例:複数フィールドの検証
validatePersonV :: String -> Int -> String -> Validated (Name, Age, Email)
validatePersonV name age email =
    (,,) <$> fromResult (mkName name)
         <*> fromResult (mkAge age)
         <*> fromResult (mkEmail email)

-- 有効なデータ
validatePersonV "田中" 30 "tanaka@example.com"
-- => Valid (Name "田中", Age 30, Email "tanaka@example.com")

-- 複数のエラー
validatePersonV "" (-5) "invalid"
-- => Invalid [EmptyValue "name", OutOfRange "age" (-5) 0 150, InvalidFormat "email" "invalid"]

4. 列挙型とパーサー

-- | 会員種別
data Membership = Bronze | Silver | Gold | Platinum
    deriving (Show, Eq, Ord, Enum, Bounded)

-- | 文字列からパース
parseMembership :: String -> ValidationResult Membership
parseMembership s = case map toLower s of
    "bronze" -> Right Bronze
    "silver" -> Right Silver
    "gold" -> Right Gold
    "platinum" -> Right Platinum
    _ -> Left (InvalidValue "membership" ("unknown: " ++ s))

-- | 文字列へ変換
membershipToString :: Membership -> String
membershipToString Bronze = "bronze"
membershipToString Silver = "silver"
membershipToString Gold = "gold"
membershipToString Platinum = "platinum"

5. 複合データのバリデーション

Person バリデーション

-- | Person レコード
data Person = Person
    { personName :: Name
    , personAge  :: Age
    , personEmail :: Maybe Email
    , personMembership :: Maybe Membership
    } deriving (Show, Eq)

-- | 入力データ
data PersonInput = PersonInput
    { inputName :: String
    , inputAge :: Int
    , inputEmail :: Maybe String
    , inputMembership :: Maybe String
    } deriving (Show, Eq)

-- | バリデーション(do 記法で連鎖)
validatePerson :: PersonInput -> ValidationResult Person
validatePerson input = do
    name <- mkName (inputName input)
    age <- mkAge (inputAge input)
    email <- traverse mkEmail (inputEmail input)
    membership <- traverse parseMembership (inputMembership input)
    Right Person
        { personName = name
        , personAge = age
        , personEmail = email
        , personMembership = membership
        }

注文バリデーション

-- | 商品ID(PROD-XXXXX形式)
newtype ProductId = ProductId { unProductId :: String }
    deriving (Show, Eq)

mkProductId :: String -> ValidationResult ProductId
mkProductId value
    | length value == 10 &&
      take 5 value == "PROD-" &&
      all isDigit (drop 5 value) = Right (ProductId value)
    | otherwise = Left (InvalidFormat "product ID" value)

-- | 商品
data Product = Product
    { productId :: ProductId
    , productName :: String
    , productPrice :: Int
    } deriving (Show, Eq)

-- | 注文アイテム
data OrderItem = OrderItem
    { orderItemProduct :: Product
    , orderItemQuantity :: Int
    } deriving (Show, Eq)

-- | 注文
data Order = Order
    { orderItems :: [OrderItem]
    , orderCustomer :: Person
    } deriving (Show, Eq)

6. まとめ

uml diagram

パターン 用途 特徴
Either 順次バリデーション 最初のエラーで停止
Validated 並列バリデーション すべてのエラーを収集
Smart Constructor ドメインプリミティブ 型安全な値の作成
newtype 型の区別 ランタイムコストなし