GUIのアプリケーションにおいて、ユーザーが入力した内容を検証する「フォームバリデーション」は必須の技術です。
しかし、静的型付けの恩恵を受けるために Flow, TypeScript, Elm, PureScript などをつかってフロントエンドコードを書くようになると、実はフォームバリデーションだけでは足りなくなってきます。1
この記事ではフォームバリデーションを包括した新しい概念である フォームデコーディング を紹介し、Elmでフォームデコーディングを実践するために開発した elm-form-decoder を使った実例をお見せします。
軽く調べた限りではフォームの入力検証について同等の概念を実現するライブラリなどは見当たりませんでしたが、もし似たライブラリなどをご存じの方がいらっしゃればご指摘いただけると幸いです。2
サンプルアプリ
まずは現実にありそうな簡単なアプリケーションを題材にしてみましょう。
ヤギさんたちのためのSNSサービスを作ります。
このサービスを実現するには、ヤギさんの2本の指でキーボードをうまく打てるかどうかという問題を解消する必要がありますが、いったんその問題は先送りにすることとします。
また、ここでは簡単に
- ヤギさんをあたらしく登録するフォーム
- 登録されたヤギさんたちの情報を閲覧するページ
の2画面構成とします。
デモアプリを先に用意しておきましたので、イメージをよりわかりやすくするために一度遊んでみていただければと思います。
データ管理用の型と、フォームの状態管理用の型
さて、このアプリではヤギさんは以下の情報をプロフィールとして保持しています。
- なまえ
- ねんれい
- つの の ほんすう
- れんらく ほうほう
- めーるあどれす か でんわばんごう
- ほかの やぎさん への めっせーじ
- かかなくてもいいよ!
静的型を活かしてこのデータを管理するには、たとえば以下のような型を定義して使います。
(この記事ではサンプルコードを Elm で書きます)
type alias Goat =
{ name : Name
, age : Age
, horns : Horns
, contact : Contact
, message : Maybe Message
}
Name
Age
Horns
Message
などの実態は、単に
type Name
= Name String
type Age
= Age Int
のように別の場所で定義されていて、値が制約を満たしていることを保証するために使っています。
Contact
はメールか電話番号なので
type Contact
= ContactEmail Email
| ContactPhone Phone
のように定義しています。
また、message
は任意項目なので Nothing
(未入力) または Just "Hi! I'm Sakura-chan."
のような形式のどちらにも対応できるように Maybe
にしています。
とてもうまくモデル化できていますね?!
さて、ではこの型でヤギさんの登録フォームの状態を管理してみましょう。
......
......
それはむずかしいのです。
だって、ユーザーの入力はプレインテキストなのですから...
たとえば年齢の入力欄にユーザー(ヤギさん)が「にさい」と打ち込んだとします。
このフィールドは整数のみ入力可能です。「にさい」は整数ではありません。
と表示したくても、年齢の情報を Int
型で保持している Goat
型では現在の入力値を保持できないので、何らかの方法で直接 input タグの value 値を読み込む必要があります。
Elm ではこういう処理をアンチパターンとして最初からできなくしてありますが、他の言語やフレームワークでも設計を悪くする原因になりえます。3
また、連絡方法のフィールドについてはどうでしょうか?
メールの入力欄と電話番号の入力欄を両方とも最初から表示しておくと、ヤギさんは
「あれ? どちらかひとつじゃないの? ぶめめぇ...」
と混乱してしまいます。
それを避けるために、「連絡方法」として
- めーるあどれす
- でんわばんごう
のどちらかを選ばせる選択肢だけを先に表示しておいて、一方を選んだ段階で初めてメールアドレスの入力欄または電話番号の入力欄を表示するようにします。
では、優柔不断なヤギさんが「めーるあどれす」を選んでメールアドレスの入力欄に入れた後で、やっぱり「でんわばんごう」を選び直したあとで「う〜ん、やっぱり めーるあどれす がいい!」と戻したとします。
このときに前回入力したメールアドレスが残っていると親切な設計だと言えます。
しかし、Goat
型では連絡方法を Contact
型で管理しており、最初にメールアドレスを入力した際に
UseEmail "you-goat-a-mail@example.com"
として保存されたあとで電話番号を選択すると UsePhone ""
で上書きされてしまい、もとのメールアドレスは消えてしまいます。
このように、フォームの状態を管理するのに Goat
型は不適切なのです。
フォームデコーディングの必要性
では、Goat
型とは別に、登録フォームの状態を管理するためだけの RegisterForm
という型を定義してみましょう。
type alias RegisterForm =
{ name : String
, age : String
, horns : String
, email : String
, phone : String
, contactType : String
, message : String
}
愚直です。実に愚直です。ぐちょぐちょ愚直です。
ユーザーが入力した値をそのまま文字列として各フィールドに保持するだけです。
contactType
は連絡方法の選択値を保持するものなので列挙型を定義して
type ContactType
= UseEmail
| UsePhone
この ContactType
にしても良さそうな気もしますが、残念ながら HTML の select
タグは融通が効きません。
selectTag.value
には文字列が入っています。
その値をそのまま保持するために contactType
も String
型にしています。
すでにお気づきとは思いますが、この時点で RegisterForm
を Goat
に変換する方法が必要です。
ユーザーの入力が終わったあとも RegisterForm
型でヤギさんのプロフィール情報を管理していたのでは、せっかくの型の恩恵を受けられないどころか、
実質動的型付けと変わらない安全性で無駄に型をごちゃごちゃしないといけなくなって、最初から動的型付けで作ったほうが良くなってしまいます。
このように、ユーザーが入力した文字列ベースの情報(文字列にエンコードされていると見なす)をデコードして、制約を満たすことが保証されている型に変換する処理を フォームデコーディング と呼んでいます。
フォームデコーディングはフォームバリデーションを包括する
賢い読者の方は思ったはずです。
「いや、フォームデコーディングが必要なのはわかったけど、別にフォームバリデーションを置き換えるものじゃなくね?」と。
そうです。ここまでの話ではフォームデコーディングはフォームバリデーションとはまた別の目的の技術です。
でも、フォームデコーディングが失敗する可能性があることを考えると話が変わってきます。
たとえば、ユーザーの入力状態として、以下の値が RegisterForm
に保持されているとします。
type alias RegisterForm =
{ name = "さくらちゃんは"
, age = "かしこいので"
, horns = "SNSもつかえるよ"
, email = ""
, phone = ""
, contactType = ""
, message = "れんらく ほうほう ってなに?"
}
これは明らかに Goat
型に変換できません。
まず年齢に数字が入っていないので
type Age
= Age Int
と定義された Age
型で表現できませんし、必須項目の連絡方法も指定されていません。
もちろん、これはいいことで、Goat
型の強い制約によって不正な値を作ることができなくするのに成功しているのです。
このことを考えると、RegisterForm
から Goat
への変換関数はたとえば以下のような型を持ちます。
toGoat : RegisterForm -> Maybe Goat
不正な入力値の時には Nothing
が返される関数です。
あるいは以下のように、失敗する理由まで丁寧に教えてくれる関数にしてもいいでしょう。
type Error
= NameRequired
| AgeInvalidInt
| AgeNegative
| AgeRequired
...
...
toGoat2 : RegisterForm -> Result (List Error) Goat
入力値が不正な場合は
Err [ NameRequired, AgeInvalidInt ]
のように不正である理由を示し、正しい場合のみ
Ok (Goat "Sakura-chan" 2 ......)
と、Ok
でくるんで変換後の値を返してくれます。
ここまで見ると、もうこれはフォームバリデーションも行っていることに気づくはずです。
このように、フォームデコーディングはフォームバリデーションを包括する概念 なのです。
フォームバリデーションと別にあつかうことの問題点
もちろん、「使い慣れたフォームバリデーションライブラリを使いたい」などの理由で、フォームバリデーションとフォームデコーディングを別々に行いたいこともあるでしょう。
しかし、それには大きな問題があります。
1つ目は、単純に2度手間になること。
結局バリデーションのために書いたコードとほとんど同じコードをデコーディングのために書かないといけません。
2つ目はもっと深刻な問題で、エラーの原因になること。
別々に同じようなコードを書いていると、
- バリデーションは通るけど
- デコードには失敗する
ようなシチュエーションが発生しかねません。
たとえば以下のような困ったコードになります。
type alias Model =
{ registerForm : RegisterForm
, goats : List Goat
}
{-| バリデーション用の関数
-}
errors : RegisterForm -> List Error
errors = ...
{-| デコード用の関数
-}
toGoat : RegisterForm -> Maybe Goat
toGoat = ...
{-| 「登録」ボタンを押した時に呼ばれてモデルの状態を更新する関数
先にバリデーション関数で入力値をチェックしているので、不正な入力のときには呼ばれない
-}
onSubmit : Model -> Model
onSubmit model =
if List.isEmpty (errors model.registerForm) then
let
goat : Maybe Goat
goat = toGoat model.registerForm
in
-- うわぁ。バリデーション成功してるのにGoatに変換できないときどうしたらいいんだぁっ
...
...
ということで、これからの時代はフォームバリデーションではなくフォームデコーディングが必要なのです。
elm-form-decoder のご紹介
フォームデコーディングを実現しようと思うと、1点重要な点があります。
それは「部分的なデコード関数を組み合わせて大きなデコード関数を組み立てられること」です。
言葉で説明してもよくわからないので、ここで僕が作った elm-form-decoder を使いながら実例を見ていきましょう。
なお、ここで取り上げるものは説明を簡単にするためにいくつか僕が実際にアプリ開発するときよりも単純にしてあります。
本気で作った場合のサンプルもあるので、興味がでたら見てみてください。
実際のフォームでは各入力欄ごとに、その入力欄の直下にその入力欄のエラーを表示することが多いです。
ちょうど冒頭で挙げたサンプルも、たとえば "Age" のところに "foo" などと入力してフォーカスを外すと "Invalid input. Please input integer." と直下にエラーが表示されます。
これを実現するためには、まずフィールドごとにデコーディング用の関数を作る必要があります。
まずは理解できなくてもいいのでなんとなく以下のコードに目を通してください。
import Form.Decoder as Decoder
{-| Decoder for name field.
import Form.Decoder as Decoder
Decoder.run name ""
--> Err [ NameRequired ]
Decoder.run name "foo"
--> Ok "foo"
-}
name : Decoder String Error String
name =
Decoder.identity
|> Decoder.assert (Decoder.minLength NameRequired 1)
{-| Decoder for age field.
import Form.Decoder as Decoder
Decoder.run age ""
--> Err [ AgeRequired ]
Decoder.run age "foo"
--> Err [ AgeInvalidInt ]
Decoder.run age "-30"
--> Err [ AgeNegative ]
Decoder.run age "30"
--> Ok 30
-}
age : Decoder String Error Int
age =
Decoder.identity
|> Decoder.assert (Decoder.minLength AgeRequired 1)
|> Decoder.pass (Decoder.int AgeInvalidInt)
|> Decoder.assert (Decoder.minBound AgeNegative 0)
まず注目すべきは、name
や age
の型が
name : String -> Result Error String
age : String -> Result Error Int
などではないことです。
代わりに以下のような型になっています。
name : Decoder String Error String
age : Decoder String Error Int
ここで定義されている name
や age
は
デコードを行う関数それ自身ではなく、デコードの方法を記した指南書なのです。
Decoder input err a
という型の指南書は、input
型の値を a
型にデコードするもので、入力値が不正な場合は err
型のエラーを返します。
この指南書の通りに入力値をデコードするには、elm-form-decoder が用意している
run : Decoder input err a -> input -> Result (List err) a
を使って
Decoder.run age ""
--> Err [ AgeRequired ]
Decoder.run age "30"
--> Ok 30
のように使います。
なぜ直接デコード用の関数にするのではなく、デコードの方法を記した指南書を作っているかというと、
先に述べた「部分的なデコード関数を組み合わせて大きなデコード関数を組み立てられる」ためです。
まずは、先ほど定義した指南書を使って新しい指南書を作ってみましょう。
name
や age
は String
型の値を入力としてとっていましたが、それらを使ってRegisterForm
型の値を入力にするデコーダーを作ってみます。
name_ : Decoder RegisterForm Error String
name_ = Decoder.lift .name name
age_ : Decoder RegisterForm Error Int
age_ = Decoder.lift .age age
単に RegisterForm
型の値から対象のフィールドの値を取り出す .name
や .age
を与えて Decoder.lift
を使っているだけです。
ここで定義した age_
や name_
などを組み合わせることで、RegisterForm
を Goat
に変換するデコーダーを作成できるのです。
form : Decoder RegisterForm Error Goat
form =
Decoder.top Goat
|> Decoder.field name_
|> Decoder.field age_
|> Decoder.field horns_
|> Decoder.field contact_
|> Decoder.field memo_
なんと、たったこれだけです。
ほんとにこれだけなんです。
各フィールド用のデコーダーを並べただけです。
指南書である Decoder を使うことで、このような簡潔な記述が可能になるのです。
あとはこの form
を使って
Decoder.run form (RegisterForm "Sakura-chan" "2" "0" ...)
--> Ok (Goat "Sakura-chan" 2 0 ...)
のように RegisterForm
から Goat
への変換も可能になるのです。
以上、フォームデコーディングの紹介と elm-form-decoder による実例でした。
もし「elm-form-decoder おもしろいじゃん」と思ったら Githubリポジトリ にスターをくださいm(_ _)m
また、elm-form-decoder自体の使い方について、詳しくて丁寧でわかりやすくて正確な説明を@miyamo_madoka さんが書いてくれました。
「arowM/elm-form-decoderのAPIを【かんぜんりかい】しよう!」
こちらも合わせてご覧ください。
さくらちゃんにご飯をあげる
さくらちゃんをもっと見る
他の記事を見る