6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

[Elm] 型安全は堅苦しくない? シンプルかつ柔軟にHTMLのイベントを捌いてみよう!

Posted at

Elmは静的型付けで、さらに制約の強い型システムを採用したプログラミング言語です。こう聞くと、同じWEBフロントで活躍するJavaScript等と比較すると、何やらとっつきづらそうで書くのが大変そうなイメージを持ちます。確かにElmは実行時に安全に動かすために、コンパイラが危険なコードを防ぐため、コードを書くのが煩わしい部分があります。今回では誰もが一度はそう思うであろうHTMLのイベントに関する煩わしいコードの回避法と逆に一度、扱いやすい型が確定すると逆に堅苦しさが消えて良いということをお見せしたいと思います。今回は以下の二つのコードを通して説明を行っていきたいと思います。なおスライダーの例は、とりさんの例を使用させていただきました。ありがとうございます!

書き手が絶対ありえないと思う煩わしい分岐

まず初めに以下のような、年齢を選択するセレクトボックスを考えてみましょう。

スクリーンショット 2020-07-11 7.34.33.png

selectとoptionのリストを組み合わせることでセレクトボックスを構築します。せっかくElmなので、セレクトボックスには必ず年齢を表す整数(Int)しか来ないように、ageOptionsという内部関数を定義します。これで書き手は、必ず整数しか選択されないことを知っています。テストなんて書きようがありません。勝ちました。ご視聴ありがとうございました。

view : Model -> Html Msg
view model = 
    let
        ageOptions : List Int -> List (Html Msg)
        ageOptions  intList =
            intList  |> List.map
                    (\age ->
                        option [ value <| String.fromInt age ] [ text <| String.fromInt age ]
                    )
    in
    div []
        [ select [ onChange UpdateAge ]
            (ageOptions <| List.range 0 50)
        , span [] [ text <| "あんたは" ++ String.fromInt model.age ++ "歳" ]
        ]

・・・がしかし、現実は非常です。UpdateAgeが受け取るのはStringです。String.toIntでMaybeをパターンマッチし、Nothingには来ないことを知りつつも無駄な分岐を書くことになります。これはElmが間違っているわけではありません。Htmlのtarget.valueには制約上Stringしか渡すことができません。Stringが来る以上、整数の変換は失敗を考慮しなければなりません。しかし、そうなると気合を入れて内部関数を定義した部分が無駄に感じてしまいます。これは煩わしいですね・・・。

type Msg
    = UpdateAge String


update : Msg -> Model -> Model
update msg model =
    case msg of
        UpdateAge ageText ->
            case String.toInt ageText of
                Just age ->
                    { model | age = age }

                Nothing ->
                    model

イベントが渡してくる値を加工して型を確定させよう

ElmでHTMLのイベントに詳しくなろうの記事にも書いた内容ですが、selectboxが持っている値(状態)はJavaScriptがchangeイベントの発火時にコールバック関数よりtarget.valueが運んでくる内容です。それをElmがただ乗りしているためStringが値として運ばれてきます。その値は、Html.Events.targetValue関数の返す値をJson.Decoderで任意のmsgに加工することができ、Html.Events.on関数で自由に発火することができるのです。Stringの値をIntに変換し分岐を書く部分は同じですが、正常な処理を行うmsgをintMsgとして注入してあげ、起こり得ない処理はnoOp(msg)として空振りさせて上げ、msgを大きく分岐させることで関心ごとの分離を行っています。しかも、この関数は一度定義してしまえば、Intとして扱いたいときにどこにでも使い回すことができます。さらにどのイベントでも使えるように一般化することができますが、それは次のスライダーで扱います。

onIntChange : msg -> (Int -> msg) -> Html.Attribute msg
onIntChange noOp intMsg =
    Html.Events.targetValue
        |> Json.map
            (\inputText ->
                case String.toInt inputText of
                    Just inputInt ->
                        intMsg inputInt

                    Nothing ->
                        noOp
            )
        |> Html.Events.on "change"

それでは完成品のModelとViewになります。先ほどの違いとしては、onIntChange関数にNoOpとUpdateAgeを渡すというイベントに変わっただけです。

type alias Model =
    { age : Int }


initialModel : Model
initialModel =
    { age = 0 }

view : Model -> Html Msg
view model = 
    let
        ageOptions : List Int -> List (Html Msg)
        ageOptions  intList =
            intList  |> List.map
                    (\age ->
                        option [ value <| String.fromInt age ] [ text <| String.fromInt age ]
                    )
    in
    div []
        [ select [ onIntChange NoOp UpdateAge ]
            (ageOptions <| List.range 0 50)
        , span [] [ text <| "あんたは" ++ String.fromInt model.age ++ "歳" ]
        ]

それでは煩わしかったupdate関数を見てみましょう。そうです。UpdateAgeの中は何もすることがなくなりました。これでageが晴れてIntであることが確定したため、自由に計算することができ柔軟で安全な世界が訪れます。

type Msg
    = UpdateAge Int
    | NoOp


update : Msg -> Model -> Model
update msg model =
    case msg of
        UpdateAge age ->
            { model | age = age }

        NoOp ->
            model

おまけ(もっともっと安全に)

今回はNoOpで異常な値を空振りさせましたが、型安全で保証した上で、万が一の異常を検知したい場合は、ポート経由でSentryエラー通知をすることもできます。滅多に異常検知をする必要がないElmですが、不安な分岐が多い場合には有効活用すると良いでしょう。

システムが間違った値を送ってくるわけないはずでは? スライダー

それでは、スライダーについてみていきます。Htmlでは、<input type="range">(MDN)を使うことでスライダーを用意できます。しかしイベント発火時に送られてくるtarget.valueの値はやはり、Stringです。スライダーは不動小数点を扱え、さらにinput(動かしている間)とchange(動かし終わり)の二つのイベントを扱うため、加工処理を抽出したonFloatEvent関数で一般化しています。今回はスライダーが100%数値以外を送ってこないと確信した上で、NoOpではなくMaybe.withDefaultで0.0に丸め込みます。

onFloatEvent : String -> (Float -> msg) -> Html.Attribute msg
onFloatEvent eventName floatMsg =
    let
        inputText2FloatMsg : String -> msg
        inputText2FloatMsg inputText =
            floatMsg <| Maybe.withDefault 0.0 <| String.toFloat <| inputText
    in
    Html.Events.targetValue
        |> Json.map inputText2FloatMsg
        |> Html.Events.on eventName


onFloatChange : (Float -> msg) -> Html.Attribute msg
onFloatChange floatMsg =
    onFloatEvent "change" floatMsg


onFloatInput : (Float -> msg) -> Html.Attribute msg
onFloatInput floatMsg =
    onFloatEvent "input" floatMsg

ModelとViewの定義になります。

type alias Model =
    { value : Float }


initialModel : Model
initialModel =
    { value = 0.0 }


view : Model -> Html Msg
view model =
    Html.div []
        [ sliderFloatView -3.14 3.14 0.01 model.value SliderChanged
        , text <| String.fromFloat model.value
        ]


sliderFloatView min max step value change =
    Html.input
        [ Attr.type_ "range"
        , Attr.min <| String.fromFloat min
        , Attr.max <| String.fromFloat max
        , Attr.step <| String.fromFloat step
        , Attr.value <| String.fromFloat value
        , onFloatChange change
        , onFloatInput change
        ]
        []

update関数です。スライダーを扱っているのですから、updateは素直にFloatの値を受け取るだけにしたいですね。全然煩わしくないです!

type Msg
    = SliderChanged Float


update : Msg -> Model -> Model
update msg model =
    case msg of
        SliderChanged value ->
            { model | value = value }

まとめ

Elmは他の言語からすると一見、慎重すぎる部分があるとっつきづらい言語に見えます。しかし、一度扱いたい型に固定されると豊富な関数群がデフォルトで用意されているため、とても柔軟で表現力豊かになります。今回はHtml Eventにフォーカスして異常な値の変換を一極集中させることで煩わしさの回避をしました。それでは楽しいElmライフを!

6
3
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?