Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
Help us understand the problem. What is going on with this article?

フォームバリデーションからフォームデコーディングの時代へ

More than 1 year has passed since last update.

GUIのアプリケーションにおいて、ユーザーが入力した内容を検証する「フォームバリデーション」は必須の技術です。

しかし、静的型付けの恩恵を受けるために Flow, TypeScript, Elm, PureScript などをつかってフロントエンドコードを書くようになると、実はフォームバリデーションだけでは足りなくなってきます1

この記事ではフォームバリデーションを包括した新しい概念である フォームデコーディング を紹介し、Elmでフォームデコーディングを実践するために開発した elm-form-decoder を使った実例をお見せします。
軽く調べた限りではフォームの入力検証について同等の概念を実現するライブラリなどは見当たりませんでしたが、もし似たライブラリなどをご存じの方がいらっしゃればご指摘いただけると幸いです。2

サンプルアプリ

まずは現実にありそうな簡単なアプリケーションを題材にしてみましょう。
ヤギさんたちのためのSNSサービスを作ります。

このサービスを実現するには、ヤギさんの2本の指でキーボードをうまく打てるかどうかという問題を解消する必要がありますが、いったんその問題は先送りにすることとします。

また、ここでは簡単に

  • ヤギさんをあたらしく登録するフォーム
  • 登録されたヤギさんたちの情報を閲覧するページ

の2画面構成とします。

デモアプリを先に用意しておきましたので、イメージをよりわかりやすくするために一度遊んでみていただければと思います。

Screen Shot 2019-04-21 at 15.29.09.png

データ管理用の型と、フォームの状態管理用の型

さて、このアプリではヤギさんは以下の情報をプロフィールとして保持しています。

  • なまえ
  • ねんれい
  • つの の ほんすう
  • れんらく ほうほう
    • めーるあどれす か でんわばんごう
  • ほかの やぎさん への めっせーじ
    • かかなくてもいいよ!

静的型を活かしてこのデータを管理するには、たとえば以下のような型を定義して使います。
(この記事ではサンプルコードを 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 にしています。

とてもうまくモデル化できていますね?!

さて、ではこの型でヤギさんの登録フォームの状態を管理してみましょう。
......
......
p2017.jpg

それはむずかしいのです。
だって、ユーザーの入力はプレインテキストなのですから...
たとえば年齢の入力欄にユーザー(ヤギさん)が「にさい」と打ち込んだとします。

このフィールドは整数のみ入力可能です。「にさい」は整数ではありません。

と表示したくても、年齢の情報を 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 には文字列が入っています。
その値をそのまま保持するために contactTypeString 型にしています。

すでにお気づきとは思いますが、この時点で RegisterFormGoat に変換する方法が必要です。
ユーザーの入力が終わったあとも 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)

まず注目すべきは、nameage の型が

name : String -> Result Error String
age : String -> Result Error Int

などではないことです。

代わりに以下のような型になっています。

name : Decoder String Error String
age : Decoder String Error Int

ここで定義されている nameage
デコードを行う関数それ自身ではなく、デコードの方法を記した指南書なのです。

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

のように使います。
なぜ直接デコード用の関数にするのではなく、デコードの方法を記した指南書を作っているかというと、
先に述べた「部分的なデコード関数を組み合わせて大きなデコード関数を組み立てられる」ためです。

まずは、先ほど定義した指南書を使って新しい指南書を作ってみましょう。
nameageString型の値を入力としてとっていましたが、それらを使って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_ などを組み合わせることで、RegisterFormGoat に変換するデコーダーを作成できるのです。

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を【かんぜんりかい】しよう!
こちらも合わせてご覧ください。

さくらちゃんにご飯をあげる
さくらちゃんをもっと見る
他の記事を見る
P2019.jpg


  1. ネイティブアプリ開発については詳しくないのでここでは触れていませんが、静的型付けを使っていれば話は同じはずです 

  2. ただし、僕は感情を持ったひとりの人間なので「こんなのもう10年前からあるだろww情弱乙w」などとコメントされると悲しい気持ちになり傷つきます。まっとうな人間として当然の最低限の配慮あるコメントをいただけると嬉しいです。 

  3. もちろん諸説あると思いますが、本記事の本題ではないのでここでは「避けるべき設計」という前提でいったん読み進めてください。すごく何か言いたくて仕方ないのであればこの記事に長文コメントするよりも何かご自身で記事を書いていただくことが良識ある行動だと思います。 

arowM
ヤギさんとして自由に生きてるよ さくらちゃんはアーティストだから世の理不尽には頭突きしちゃうよ フリーランスUXハッカー・プログラマー(Elm, Haskell)・技術翻訳・ヤギ語翻訳 ARoW代表 http://arow.info /気吹堂(出版)代表/UZUZ CXO http://github.com/arow
https://arow.info
arow-oss
もともと法人だったけど潰しちゃったよ c.f., https://qiita.com/arowM/items/9eddd10d531154cbc065
https://arow.info
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away