PureScriptでフォームデコーディング

# tl;dr

PureScriptでデータデコーディングを実践するのであれば、パーサライブラリよりもpurescript-validationを使うほうが柔軟でよさそう。
ただ型変換とバリデーションが複雑に絡むときに、生で使うよりもヘルパー関数とかがあるほうが便利。
というわけで作ってみた → [purescript-form-decoder](https://pursuit.purescript.org/packages/purescript-form-decoder)

### 2019-06-23追記

[purescript-halogen-formless](https://github.com/thomashoneyman/purescript-halogen-formless) というライブラリがあったようだ。
Halogen との併用限定だが、フォームの構築や入力値検証の実施などやってくれるっぽい。


# はじめに

ユーザーが入力した内容を検証する「フォームバリデーション」は必須の技術。
これはGUIアプリケーションに限らずユーザー入力を受け付けるシステムであればいつも必要で、そのくせスッキリと書くのが妙に難しい厄介なやつ。

以前PureScriptでSPAを作ったときに型変換と入力値検証がごちゃごちゃしてしまったこともあり、どう考えれば整理できるのだろうと思っていた。

[フォームバリデーションからフォームデコーディングの時代へ](https://qiita.com/arowM/items/6c32db1f9e4b92445f3b)

この記事によれは型変換と入力値検証は別々ではなく、フォームデコーディングというより大きな枠組みの中で一つになるとのこと。

なるほど、と思った。記事のコメントにもあるように、こういうのを待っていた。

ただ悲しいかな記事で紹介されている「[elm-form-decoder](https://package.elm-lang.org/packages/arowM/elm-form-decoder/latest/)」はElm用で、PureScriptで書かれたプロジェクトにはそのまま使うことができない。
そこでPureScriptでフォームデコーディングをやるにはどうしたらいいか、試してみた。

# 方針

元記事のコメントにもあるように「elm-form-decoder」を使って書かれたデコーダーはパーサーコンビネータを彷彿とさせる（実際コンビネータなのだろう）。そこでPureScriptにもすでに存在するパーサーライブラリを使って同じことができないか試してみた。

また、PureScriptには検証のための型が定義されたパッケージ「[purescript-validation](https://pursuit.purescript.org/packages/purescript-validation/)」がすでにあって、公式の入門書["PureScript by Example"](https://leanpub.com/purescript/read)の7章で紹介されている。`map`や`apply`を使って複数の検証関数を組み合わせていくあたりがコンビネータっぽい。ということでこちらも試してみた。


# 実験

以下の完全なサンプルはGitHubを参照。 → [https://github.com/amderbar/try-form-decoding](https://github.com/amderbar/try-form-decoding)

実験するにあたって、題材として元記事にもあるやぎさんのSNSサービスを取り上げる。

以下のようなデータ管理用の型`Goat`と、フォームの状態管理用の型`RegisterForm`が定義されているとする[^1]。

[^1]: この記事ではコードはPureScriptで記述している。色付けのためにコードブロックにhaskellと指定しているが……

```haskell
type Goat =
    { name    :: Name
    , age     :: Age
    , horns   :: Horns
    , contact :: Contact
    , message :: Maybe Message
    }
```

```haskell
type RegisterForm =
    { name        :: String
    , age         :: String
    , horns       :: String
    , email       :: String
    , phone       :: String
    , contactType :: String
    , message     :: String
    }
```

ここで`Name`や`Age`といった型は別の場所で定義されていて、値が制約を満たしていることを保証するためのものだ。

```haskell
newtype Name = Name String
newtype Age = Age Int
```

このような状況設定のもとで、`RegisterForm`から`Goat`への変換関数を作ることでフォームデコーディングを試してみた。

```haskell
toGoat :: RegisterForm -> Either Error Goat
```

`Error`型は割と何でもいいのだが、ArrayやListといったSemigroup型クラス制約を満たすものを想定している。

## パーサライブラリ

パーサライブラリとして今回は「[purescript-simple-parser](https://pursuit.purescript.org/packages/purescript-simple-parser/)」を使用した。
PureScriptにはほかにも、HaskellのParsecをもとにした[purescript-parsing](https://pursuit.purescript.org/packages/purescript-parsing)というライブラリがあるが、これはモジュールも多くて使い方がいまいちよくわからなかったのでsimpleなほうにした。purescript-simple-parser自体がpurescript-parsingのtransformerless版とのことなので基本的には問題ないはずだ。

purescript-simple-parserのメインとなるモジュールは Text.Parsing.Simple というものだ。この中に基本的な型や関数が定義されている。
中でも中心的なのは`Parser`型で、こいつを組み合わせて目的のパーサを作っていく。そして`parse`関数にパース対象の文字列と一緒に渡すことで結果が得られる。

```haskell
parse :: forall i o. Parser i o -> i -> Result o
```

ここで`i`、`o`はそれぞれ入力と出力の型変数であり`Result`は以下のように定義された処理結果を保持するための型だ。

```haskell
type Result o = Except (List ParseError) o
```

`ParseError`は単に`String`の型別名となっている。`Except`は別途purescript-transformerlessパッケージのControl.Monad.Transformerless.Exceptモジュールで定義されている`runExcept`関数を使えば`Either`に変換できる。

さて、これを使って目的のパーサを作っていこう。

```haskell
import Text.Parsing.Simple                    (Parser, (|=), (<?>))
import Text.Parsing.Simple                  as Parser

name_ :: forall r. Parser { name :: String | r } Name
name_ = Name <$> ((_.name <$> Parser.stream) |= (not <<< Str.null) <?> "NameRequired")

age_ :: forall r. Parser { age :: String | r } Age
age_ = Age <$> do
  digit <- (_.age <$> Parser.stream) |= (not <<< Str.null) <?> "AgeRequired"
  age <- case fromString digit of
    Just i  -> pure i
    Nothing -> Parser.fail "AgeInvalidInt"
  if age >= 0
    then pure age
    else Parser.fail "AgeNegative"
```

例として`Name`用パーサと`Age`用パーサを見ていく。

`Parser.stream`は受け取った入力をそのまま返す`identity`的なパーサだ。それを`map`すれば出力を加工できる。`_.name <$> Parser.stream`とした場合、`RegisterForm`を受け取って`String`を返すパーサにしたことになる。`|=`は第1引数のパーサの出力に対して第2引数の関数を適用し、偽だったらそこでパース失敗とする演算子。`<?>`で失敗時のエラーメッセージを指定できる。
`Name`用パーサはつまり、`RegisterForm`を受け取って`name`フィールドを取り出し、それが空文字じゃなかったらOKそのまま返す、というパーサだ。

`Age`用パーサは`Name`用パーサと同じく空文字チェックの後で、文字列を整数に変換し、それが負数ではないことをチェックしている。`Parser`はMonad型クラスのインスタンスなので、do式を使ってパース出力に対する処理を書いていくことができる。
ちなみに Text.Parsing.Simple には`int`というパーサも用意されているのだが、空文字チェックをした後で`int`につなげる方法がよくわからなかったのでこういう実装になった。

これら各フィールドに対するパーサを Applicative スタイルでつなげていけば、目的のパーサも作ることができる。

```haskell
toGoat :: RegisterForm -> Either (List Parser.ParseError) Goat
toGoat = runExcept <<< (Parser.parse goat)

goat :: Parser RegisterForm Goat
goat = { name: _, age: _, horns: _, contact: _, message: _ }
  <$> name_
  <*> age_
  <*> horns_
  <*> contact_
  <*> message_
```

これを使っていろいろな`RegisterForm`からの変換を試してみよう。

```haskell
do
  logShow $ toGoat { name: "", age: "", horns: "", contactType: "", email: "", phone: "", message: "" }
  logShow $ toGoat { name: "foo", age: "bar", horns: "baz", contactType: "ContactHoge", email: "", phone: "", message: "" }
  logShow $ toGoat { name: "foo", age: "-30", horns: "-1", contactType: "ContactEmail", email: "", phone: "", message: "message" }
  logShow $ toGoat { name: "foo", age: "30", horns: "3", contactType: "ContactPhone", email: "", phone: "", message: "message" }
  logShow $ toGoat { name: "foo", age: "30", horns: "2", contactType: "ContactEmail", email: "hoge@example.com", phone: "", message: "" }
  logShow $ toGoat { name: "foo", age: "30", horns: "2", contactType: "ContactPhone", email: "", phone: "0X0-ABCD-WXYZ", message: "message" }
```

出力は以下の通りだ。

```haskell
(Left ("NameRequired" : Nil))
(Left ("AgeInvalidInt" : Nil))
(Left ("AgeNegative" : Nil))
(Left ("HornsTooMany" : Nil))
(Right { age: (Age 30), contact: (ContactEmail (Email "hoge@example.com")), horns: (Horns 2), message: Nothing, name: (Name "foo") })
(Right { age: (Age 30), contact: (ContactPhone (Phone "0X0-ABCD-WXYZ")), horns: (Horns 2), message: (Just (Message "message")), name: (Name "foo") })
```

正常時はいいとして、エラーの時複数フィールドにエラーがあるはずの場合でも一つずつしかメッセージが出ていない。

## purescript-validation

purescript-validationが上述の通り、公式の入門書["PureScript by Example"](https://leanpub.com/purescript/read)の7章で紹介されているバリデーション用のライブラリだ。
とはいえそこで定義されているのは検証結果保持用の`V`という型とその他いくつかの関数のみであり、文字列からその他の型に変換する関数などは特にない。

パーサライブラリと同様に、`Name`と`Age`を得る部分を例として出す。以下のようにしてみた。

```haskell
import Data.Validation.Semigroup   (V(..))
import Data.Validation.Semigroup as V

name_ :: forall r.  { name :: String | r } -> V (Array String) Name
name_ = _.name
  >>> (\n -> required "NameRequired" n *> pure n)
  >>> map Name

age_ :: forall r. { age :: String | r } -> V (Array String) Age
age_ =
  _.age >>>
  (\n -> required "AgeRequired" n
    `V.andThen`
    (\_ -> int "AgeInvalidInt" n)
    `V.andThen`
    (\m ->
      minBound "AgeNegative" 0 m *>
      pure m
    )
  ) >>>
  map Age

-- helper functions

chk :: forall e v. e -> (v -> Boolean) -> v -> V (Array e) Unit
chk e r v = if r v
    then pure unit
    else V.invalid [e]

required :: forall e. e -> String -> V (Array e) Unit
required err = chk err (not <<< Str.null)

int :: forall e. e -> String -> V (Array e) Int
int e = V <<< Either.note [ e ] <<< Int.fromString

minBound :: forall e a. Ord a => e -> a -> a -> V (Array e) Unit
minBound err bound = chk err (_ >= bound)

maxBound :: forall e a. Ord a => e -> a -> a -> V (Array e) Unit
maxBound err bound = chk err (_ <= bound)
```

どちらも型が`input -> V (Array String) output`となっている。`V`型自体は検証結果を表すため、検証ステップ自体は関数合成と`andThen`を使って表現することになった。`V`は`Bind`型クラスのインスタンスになっていないので`bind`を使うことはできないが、`andThen`はそれに相当する関数だ。またいくつかのヘルパー関数を自作することになった。

各フィールドに対する検証が関数なので、それらを組み合わせる際もそれぞれに引数を渡す形になる。

```haskell
toGoat :: RegisterForm -> Either (Array String) Goat
toGoat form =
  V.toEither $ { name: _, age: _, horns: _, contact: _, message: _ }
    <$> name_ form
    <*> age_ form
    <*> horns_ form
    <*> contact_ form
    <*> message_ form
```

こちらでもパーサライブラリの時と同じ入力を与えて出力を見てみると、以下のようになった。

```haskell
(Left ["NameRequired","AgeRequired","HornsRequired","ContactTypeRequired"])
(Left ["AgeInvalidInt","HornsInvalidInt","ContactTypeInvalid"])
(Left ["AgeNegative","HornsNegative","EmailRequired"])
(Left ["HornsTooMany","PhoneRequired"])
(Right { age: (Age 30), contact: (ContactEmail (Email "hoge@example.com")), horns: (Horns 2), message: Nothing, name: (Name "foo") })
(Right { age: (Age 30), contact: (ContactPhone (Phone "0X0-ABCD-WXYZ")), horns: (Horns 2), message: (Just (Message "message")), name: (Name "foo") })
```

こちらも正常時はいいとして、エラーの時複数フィールドにエラーがあるはずの場合にはすべてのフィールドに対してメッセージが出ている。

# 所感

「purescript-simple-parser」を使った場合、それぞれ独立したフィールドでエラーが発生していても引数の順で先に検証したフィールドしかエラーが記録されない。これはエラーが発生した時点で後の部分のパースがまるっとスキップされていると考えれば納得できる。エラー情報の格納は`List`なので複数のエラーに対応できるのではないかと思われるが、今回の使い方ではうまくいかないようだ。
またモジュール名が`Text.Parsing.Simple`となっていることから、一次元の文字列情報を読み込みながら解析していくような使い方が想定されているものと想像され、今回の使い方だと無理やり感が否めない（使い方がよくわかっていないだけかもしれないが）。
ただ「purescript-validation」と比較してよかったのは、Parserがモナドになっているため`do`記法が使用でき、複雑な検証ステップがそれなりに分かりやすく記述できた。また型変換のための関数がそろっており部品を組み合わせて目的のパーサーを作っていくことももしかしたらできそうだ。

一方「purescript-validation」は各フィールドのエラー情報を漏れなく収集できるという点がよい。["PureScript by Example"](https://leanpub.com/purescript/read)でも解説されているが`bind`ではなく`apply`を使うことでエラーが出ても検証ステップを中断することなく終えることができる。また`andThen`を使えば以前のステップでの出力に対して検証を行うということもできるため、型変換を組み込みやすい。さらに`andThen`は検証ステップの中断にも使えて柔軟な制御が可能になっている。
ただ「purescript-validation」の提供するのは検証結果を保持するための型のみなので、検証内容はいちいち自分で書くことになる。また`V`型はモナドになっていないため検証関数を組み合わせていく際にラムダ式をいっぱい書いていくことになるし、`*>`を使ってつなげる際もいちいち引数を書かなければならないのが少しうっとうしく感じられる。

# 結論

PureScriptでフォームデコーディングを実践するにあたり、二つのパッケージを使って試してみた。

* 「purescript-simple-parser」はどうも用途に合っていないっぽい
* 「purescript-validation」は合目的的っぽい
* 検証用ヘルパー関数が用意されているほうがありがたい
* `do`記法とかが使えたほうがルールを組み合わせていくときに便利

というわけで「purescript-validation」をラップしたフォームデコーディング用パッケージがあるといいのではと考えられる。そして少し探してみたけど見当たらなかったので、試しに作ってみた。
→ [purescript-form-decoder](https://pursuit.purescript.org/packages/purescript-form-decoder)


# 参考

* [フォームバリデーションからフォームデコーディングの時代へ](https://qiita.com/arowM/items/6c32db1f9e4b92445f3b)
* ["PureScript by Example"](https://leanpub.com/purescript/read)
* （↑の日本語訳）[実例によるPureScript](http://aratama.github.io/purescript/)
* [purescript-simple-parser](https://pursuit.purescript.org/packages/purescript-simple-parser/)
* [purescript-validation](https://pursuit.purescript.org/packages/purescript-validation/)