Elm入門と実践 - 買い物カートを作ってアーキテクチャ「TEA」を学ぶ

純粋関数型AltJSであるElmへの注目がじわじわと高まっています。開発者に優しい仕様を持つElmの初歩と、業務に使える実践的な活用法を泉 征冶さんが解説します。

Elm入門と実践 - 買い物カートを作ってアーキテクチャ「TEA」を学ぶ

はじめまして。Webアプリケーションエンジニアの泉です。ElmはNoRedInkのエンジニアであるEvan Czaplicki 1 @czaplic氏によって開発された、純粋関数型AltJSです。聞き慣れない言語かもしれませんが、意外にもその歴史は長く、最初のバージョンは2012年に当時学生であったEvan氏の論文とともに公開されました。

現執筆時点(2019年12月)におけるElmの最新バージョンは0.19.1で、旧来のバージョンにあった複雑でとっつきにい印象のあるFRP(Functional Reactive Programming:関数型リアクティブプログラミング)の概念などをばっさり削ぎ落とし、シンプルかつ実用性の高い言語となりました。

私の在籍するFringe81が開発するプロダクト「Unipos」では、Elmで7万行を超えるソースコードがありますが、それ以外にも、いくつかの企業がプロダクションでElmを採用しており、国内でも活用事例もみられるようになってきています。

Elmの特徴

さて、AltJS界隈にはElmに限らずReasonMLやGHSJS, Scala.jsといった、関数型プログラミング言語に分類し得るものがいくつか存在しますが、その中でElmの特徴とはどのようなものがあるのでしょうか。まずは、3つの大きな特徴をご紹介します。

Elmの特徴1. 徹底的に実行時エラーを出さない仕組み

Elmに限らず、多くの関数型プログラミング言語の基本的な考え方として、"型によってデータのとりうる形をすべてカバーする"という点が挙げられます。型によってコンパイラがコードから取りうる状態を理解し、人間に変わって状態の網羅性チェックをしてくれます。

たとえば、データの「あり/なし」や、なんらかの処理の「成功/失敗」などを型で表現することで、ある変数の存在チェックや、関数のエラーチェック忘れなどの可能性をほぼゼロにすることができます。

以下は、実装の考慮漏れの例です。

-- この実装はhasTires関数がコンパイルエラーになる


type Vehicle 
    = Taxi
    | Bus
    | Airplane


hasTires : Vehicle -> Bool
hasTires vehicle =
    case vehicle of
        Taxi ->
            True

        Airplane ->
            False

これを修正するには、以下のように抜け漏れたバリアントをカバーする変更を加えます。

hasTires : Vehicle -> Bool
hasTires vehicle =
    case vehicle of
        Taxi ->
            True

+       Bus ->
+           True

        Airplane ->
            False

これでVehicleのパターンがカバーされていることが担保されました。 このようにして、コンパイラーが必ずある型の網羅性をチェックしてくれます。Elmのビルトインの型には、存在の有無を表現するMaybe型や、ある処理の成功と失敗を表現するResult型があります。もちろん、このどちらもコンパイラによる型チェックが行われます。

また、Elmでは例外が言語レベルで存在していないことも、特筆すべき点でしょう。 ScalaやHaskellなどの言語では、例外的にランタイムエラーを起こせるメソッドが存在しますが、Elmでは一般的に例外が起こるものとされるhead関数(リストの先頭要素を取る関数)などもMaybe型を返すようになっています。例外が言語自体に存在していないため、Elmを使っている限りはランタイムエラーも存在しないのです。

Elmの特徴2. 人間に優しいコンパイルエラー

一般的に型のある言語では、コンパイラが示すエラーを修正するための型合わせ、通称「型パズル」が発生しがちです。 型パズルは型が提供する機能が多ければ多いほど複雑化する傾向がありますが、少なくない入門者が静的型付け言語に対して及び腰になってしまうのは、型に関連するコンパイルエラーの複雑性に理由の一つを求められるでしょう。

ひるがえってElmは新しく言語を学ぶ人々の体験を最高のものにすることに重点を置いて開発されています。こうした思想から、型安全性を犠牲にすることなく、限りなく解決のしやすいコンパイルエラーが出されるのがElmの特徴です。 以下が、実際にElmで見られるコンパイルエラーのメッセージの一例です。

-- TYPE MISMATCH ---------------------------- Main.elm

The 1st argument to `drop` is not what I expect:

8|   List.drop (String.toInt userInput) [1,2,3,4,5,6]
                ^^^^^^^^^^^^^^^^^^^^^^
This `toInt` call produces:

    Maybe Int

But `drop` needs the 1st argument to be:

    Int

Hint: Use Maybe.withDefault to handle possible errors.

「何番目の引数の型が間違っているか」「どう変えれば正しくなるか」「なんの関数を使えばエラーを修正できるか」などの情報をコンパイラ自体が提供してくれます。

Elmの特徴3. ビルトイン・フレームワーク

Elmではアプリケーション開発のためのフレームワークが言語とともに提供されています。 一般的なフロントエンド・アプリケーション開発の際には、npmで公開されているライブラリを自分で選びながら開発をしますが、ElmではHTTPリクエスト、ルーティング、状態管理、ビューレンダリングなどがすべてElmという言語の機能として提供されています。

また、Elmではアプリケーション全体の設計がTEA(The Elm Architecture)というアーキテクチャに則ります。TEAは以下の3つの要素から構成されるものです。

要素 役割
Model アプリケーションの状態を表現するもの
View 状態をHTMLへ変換するもの
Update メッセージをもとにアプリケーションの状態を更新するもの

このTEAは、関数型FluxライブラリであるReduxの思想に影響を与えたことで知られています。

TEAは現代のフロントエンド・アプリケーションを開発するにあたってのベストプラクティスを踏襲しており、「Elmまかせ」でアプリケーションを作ることでが、スケールするアプリケーションを開発するための最短ルートととなるように作られているのです。

ECサイトの商品カート機能をElmで作ってみよう

さて、ここからは手を動かしながら、Elmのエッセンスをお伝えしていきたいと思います。ElmによるTODOアプリカウンタアプリのサンプルは、Elmの公式ページやさまざまなブログ、書籍などで比較的よく見られますが、もう少し大きな規模でのElmのアプリケーションのコードを見る機会は多くはありません。

最も実践的なアプリケーションのサンプルとしては、NoRedInk社のリチャード・フェルドマンによるmediumのクローンを実装したrtfeldman/elm-spa-exampleがありますが、コードベースがTODOアプリなどと比べて大きく、入門直後に触れるものとしては非常に難解です。

以降では、そのような 「実践と入門のギャップ」 を埋めるテーマとして、ECサイトのカート機能のサンプルをElmのコード解説とともにご紹介します。 ECカートという入門以上大規模未満のテーマにあたるコードをご覧いただくことで、モジュール分割などの実際にElmをアプリケーション開発で使う際の雰囲気を体感しつつ、Elmの理解が深まるはずです。

2

今回のサンプルのカートには以下の機能を実装しています。

  • APIを経由した商品一覧の取得
  • 商品の選択
  • 小計, 送料, 税, 合計の計算

また、実際のコードは以下のGitHubリポジトリからアクセスできます。

サンプルアプリケーションの設計について

ここに紹介するカート機能はElmのモジュールシステムを用いて簡単な責務分割をしています。今回は、画面の要素から、主要なモジュールとなるのは商品一覧カートであることが分かるでしょう。

3

Elmにおけるモジュール分割は、コンポーネント指向のように状態を中心に分割するのではなく、型と型に対する操作(関数)を中心に分割します。

ReactやVue.jsに馴染みのある方は、コンポーネント指向の考え方に近いものを感じるかもしれません。しかし、Elmではコンポーネント指向の考え方とは異なり、個々のモジュールが個別の状態を持ちません。Elmにおける状態は、常に最上位のモジュールに当たるものが集約して表現します。一方で、Elmにおける分割されたモジュールは、文字通り責務分割された型と、それにまつわる関数群のみです。百聞は一見にしかず、ということでさっそく実際のコードを見てみましょう。

商品一覧のモジュール(Products.elm)

Model

Productsモジュールは、まず最初にModelとして定義されているProducts型がすべての中心となります。Products型はバリアントとしてProductsしか持っておらず、モジュール外にはバリアントを公開しません。これにより、Productsモジュールを使う側はモジュールから提供される関数のみを使用し、Products型を操作することになります。

このように型のデータ構造をカプセル化し、操作の方法をモジュールが提供することで、変化に強いインタフェースが生まれるのです。こうした実装パターンをElmではOpaque Typeと呼びます。

現時点でのProducts型の実装はListですが、これが仕様変更でDict型になるケースを考えてみると、Opaque Typeによって、外部のモジュールがProducts型内部のデータ構造に依存した実装をできないことが保証されています。つまり、仮に内部実装がListからDictになったとしても、実装の変更はこのモジュールだけに限定できるのです。

ElmではJavaやRubyなどで一般的なクラスに相当する機能がありません。その代わりに、ファイルをひとつのモジュールとしたうえで、公開範囲を絞ったアクセス制御とカプセル化の機能を提供しています。

-- model


type Products
    = Products (List Product)


type alias Product =
    { id : String
    , name : String
    , price : Int
    , imageUrl : String
    }


empty : Products
empty =
    Products []


getByIdSet : Set.Set String -> Products -> List Product
getByIdSet ids (Products products) =
    List.filter (\{ id } -> Set.member id ids) products

また、getByIdSet関数では引数にSet型が使用されており、これは重複のない集合です。

事実、今回のアプリケーションでは商品の持つIDが重複するケースはありません。型でSetが明示的に使用されていることで、開発者は「IDは重複しないものだ」と理解できます。Elmに限らず静的型付言語では、このようにして型そのもので仕様を表現することが重要です。

View

Products型をデストラクチャリングし、HTMLの構造に変換しています。

-- view


view : (String -> msg) -> Products -> Html msg
view onAddToCart (Products products) =
    div
        [ class "section products" ]
        (List.map (viewProduct onAddToCart) products)


viewProduct : (String -> msg) -> Product -> Html msg
viewProduct onAddToCart { name, id, imageUrl, price } =
    div
        [ class "product" ]
        [ div
            [ class "background" ]
            [ img
                [ src imageUrl
                , class "image"
                ]
                []
            ]
        , div
            [ class "name" ]
            [ text (String.concat [ "#", id, " ", name ])
            ]
        , div
            [ class "price" ]
            [ text ("¥" ++ String.fromInt price)
            ]
        , div
            [ class "actions" ]
            [ button
                [ class "add"
                , onClick (onAddToCart id)
                ]
                [ text "追加" ]
            ]
        ]
Internals

私が所属するチームでは、あるモジュールでしか使わない「モジュール・プライベート」な関数をinternalsとして区切ったセクションに配置することにしています。Productsモジュールでは、HTTPモジュールによって取得された結果をデコードする関数群をここに配置しています。

-- internals


decode : Decode.Decoder Products
decode =
    Decode.succeed (\results -> Decode.succeed (Products results))
        |> Pipeline.required "result" (Decode.list decodeProduct)
        |> Pipeline.resolve


decodeProduct : Decode.Decoder Product
decodeProduct =
    Decode.succeed Product
        |> Pipeline.required "id" Decode.string
        |> Pipeline.required "name" Decode.string
        |> Pipeline.required "price" Decode.int
        |> Pipeline.required "image_url" Decode.string

カートのモジュール(Cart.elm)

Model

Cartモジュールにはカートにまつわるビジネスルールがすべて集約されます。

送料を表すshippingや、税額を表すtaxes、 小計を表すsubTotalなどはすべてproductsIdsから計算可能な従属性のある値です。 productsIdsの値が変われば送料や税額なども変わります。これらをModelに持たせることで、ViewはHTMLの変換の際に表示する値の計算という責務を持つことなく、変換の責務に集中することができます。

こうしたModelの設計はデバッガビリティにも影響を与えます。仮にView内部で複雑な計算が行われている場合、Modelの状態からViewの結果を推測するのは困難です。こうなると、Debugモジュールなどを使ってログを吐く処理を関数の中にはさむ、といった昔ながらのデバッグ実行をする他なくなってしまい、非常に生産性が悪くなるでしょう。

-- model


type alias ProductIds =
    Set.Set String


type Cart
    = Cart
        { productIds : ProductIds
        , shipping : Int
        , taxes : Int
        , subTotal : Int
        , total : Int
        }


empty : Cart
empty =
    Cart
        { productIds = Set.empty
        , shipping = 0
        , taxes = 0
        , subTotal = 0
        , total = 0
        }


add : String -> Products.Products -> Cart -> Cart
add productId products (Cart cart) =
    let
        nextProductIds =
            Set.insert productId cart.productIds

        taxes =
            totalTaxes products nextProductIds

        shipping =
            totalShipping products nextProductIds

        subTotal_ =
            subTotal products nextProductIds
    in
    Cart
        { productIds = nextProductIds
        , shipping = shipping
        , taxes = taxes
        , subTotal = subTotal_
        , total = subTotal_ + shipping + taxes
        }
Internals

前述のとおり、Cartモジュールの最も重要な責務は、カートに関するビジネスルールを集約することです。これに付随して、カートのHTMLを組み立てるのに必要な数値(税額、配送料、合計額など)を計算することもまた、重要な責務となります。

こうしたロジックは、すべてこのモジュール内にInternalな関数として凝集させます。Internalな関数として閉じ込めることで、各種計算の責務がすべてこのCartモジュールだけで完結しているということが自明になります。今後もし何らかの仕様変更が起きて、カートで必要な数値の計算ロジックに変更があったとしても、変更範囲をこのひとつのモジュールに閉じることができるのです。

-- internals


{-| 現在選択されている商品一覧をdivのリストにする
-}
viewProductNames : Products.Products -> ProductIds -> List (Html msg)
viewProductNames products productIds =
    products
        |> Products.getByIdSet productIds
        |> List.map (\{ name, id } -> div [] [ text (String.concat [ "#", id, " ", name ]) ])


{-| 合計の税額を計算する
-}
totalTaxes : Products.Products -> ProductIds -> Int
totalTaxes products productIds =
    products
        |> Products.getByIdSet productIds
        |> List.foldl (\{ price } acc -> acc + truncate (toFloat price * 0.08)) 0


{-| 税と配送料を抜いた小計を計算する
-}
subTotal : Products.Products -> ProductIds -> Int
subTotal products productIds =
    products
        |> Products.getByIdSet productIds
        |> List.foldl (\{ price } acc -> acc + price) 0


{-| 送料を計算する。1500円を上限配送料とする。
-}
totalShipping : Products.Products -> ProductIds -> Int
totalShipping products productIds =
    let
        price =
            ceiling (toFloat (subTotal products productIds) / 500) * 500
    in
    if price < 1500 then
        price

    else
        1500
View

Modelに計算されたすべてのデータが反映されるため、Viewではなにも計算をせずデータをただHTMLへ変換するだけです。

-- view


view : msg -> Products.Products -> Cart -> Html msg
view onPurchaseMsg products (Cart cart) =
    div
        [ class "section cart" ]
        [ h2
            [ class "title" ]
            [ text "現在のカート" ]
        , div
            [ class "block selection" ]
            [ div [] (viewProductNames products cart.productIds) ]
        , div
            [ class "seperator" ]
            []
        , div
            [ class "block subtotal" ]
            [ span
                [ class "label" ]
                [ text "小計:" ]
            , span
                [ class "value" ]
                [ text (String.concat [ "¥", String.fromInt cart.subTotal ]) ]
            ]
        , div
            [ class "block shipping" ]
            [ span
                [ class "label" ]
                [ text "送料:" ]
            , span
                [ class "value" ]
                [ text (String.concat [ "¥", String.fromInt cart.shipping ]) ]
            ]
        , div
            [ class "block tax" ]
            [ span
                [ class "label" ]
                [ text "税:" ]
            , span
                [ class "value" ]
                [ text (String.concat [ "¥", String.fromInt cart.taxes ]) ]
            ]
        , div
            [ class "block total" ]
            [ span
                [ class "label" ]
                [ text "合計:" ]
            , span
                [ class "value" ]
                [ text (String.concat [ "¥", String.fromInt cart.total ]) ]
            ]
        , div
            [ class "actions" ]
            [ button
                [ class "purchase"
                , onClick onPurchaseMsg
                ]
                [ text "購入" ]
            ]
        ]

適切に設計されたアプリケーションのModelはViewのコードに現れます。Viewの中でModelのデータを複雑に計算しているとしたら、それはModelのデータ構造の設計を見直す必要があるでしょう。

Modelのデータに文字を付け加えたり、case文でパターンマッチによるHTMLの出し分けをしている程度になっているのが、Viewの責務としては理想です。

モジュールのつなぎこみ(App.elm)

Model

このAppモジュールでは、先程定義したモジュールの繋ぎこみと、カスタム型による画面の状態を定義しています。型の定義を見ると、今回のECカートには「ロード中」「ロード完了」「決済完了」の3つの画面があることが分かります。

-- model


type alias Session =
    { products : Products.Products
    , cart : Cart.Cart
    }


type Model
    = Loading
    | Loaded Session
    | Purchased Session


init : () -> ( Model, Cmd Msg )
init _ =
    ( Loading, Products.fetch ProductFetched )
Update

update関数では、メッセージごとに現在のModelの状態の組み合わせを処理します。Elmの場合、ここでModelの状態とメッセージの組み合わせの網羅性チェックが行われます。

-- update



type Msg
    = ProductFetched (Result Http.Error Products.Products)
    | AddProductToCart String
    | Purchase
    | BackToProducts


update : Msg -> Model -> ( Model, Cmd msg )
update msg model =
    case msg of
        ProductFetched products ->
            case model of
                Loading ->
                    ( Loaded
                        { products = Result.withDefault Products.empty products
                        , cart = Cart.empty
                        }
                    , Cmd.none
                    )

                Loaded _ ->
                    ( model, Cmd.none )

                Purchased _ ->
                    ( model, Cmd.none )

        AddProductToCart id ->
            case model of
                Loading ->
                    ( model, Cmd.none )

                Loaded session ->
                    ( Loaded
                        { session
                            | cart = Cart.add id session.products session.cart
                        }
                    , Cmd.none
                    )

                Purchased _ ->
                    ( model, Cmd.none )

        Purchase ->
            case model of
                Loading ->
                    ( model, Cmd.none )

                Loaded session ->
                    ( Purchased session, Cmd.none )

                Purchased _ ->
                    ( model, Cmd.none )

        BackToProducts ->
            case model of
                Loading ->
                    ( model, Cmd.none )

                Loaded _ ->
                    ( model, Cmd.none )

                Purchased { products } ->
                    ( Loaded
                        { products = products
                        , cart = Cart.empty
                        }
                    , Cmd.none
                    )    

update関数は画面状態とメッセージの組み合わせが増えることで、掛け算的にパターンマッチの行数が増えていきます。その際、適切な範囲でアンダースコアを使った省略記法を組み合わせるとよいでしょう。

View

Modelの状態に応じてcase文でパターンマッチし、HTMLを返します。各モジュールで定義されていたview関数も、ここで呼び出されます。

-- view


view : Model -> Browser.Document Msg
view model =
    { title = "ECサイト"
    , body =
        case model of
            Loading ->
                [ div [] [ text "Loading..." ] ]

            Loaded loaded ->
                [ div
                    [ class "banner" ]
                    [ h1
                        [ class "title" ]
                        [ text "ECサイト" ]
                    ]
                , div
                    [ class "contents" ]
                    [ Products.view AddProductToCart loaded.products
                    , Cart.view Purchase loaded.products loaded.cart
                    ]
                ]

            Purchased ->
                [ div
                    [ class "contents purchased" ]
                    [ div
                        [ class "container" ]
                        [ div
                            [ class "title" ]
                            [ text "Thank You!" ]
                        , button
                            [ class "back"
                            , onClick BackToProducts
                            ]
                            [ text "商品一覧へもどる" ]
                        ]
                    ]
                ]
    }
Main

ここまでで定義されたすべての関数をdocument関数へ渡します。

-- main


main : Program () Model Msg
main =
    Browser.document
        { init = init
        , view = view
        , update = update
        , subscriptions = \_ -> Sub.none
        }

【発展編】LocalStorageとの連携

さて、ここまでのコードで今回の目的となるカートのアプリケーションは一旦完成です。しかし、より実践的かつ実用的なアプリケーション開発の場では、Elmだけでは達成できない仕様に遭遇することがあります。

たとえば、Elm単体ではブラウザAPIを経由してLocalStorageやWebSocketなどの仕組みを直接使うことができません。Elmではこうした状況に対するエスケープハッチとしてPortという仕組みが用意されています。Portを使用することで、Elmの型安全性を破壊することなく、JavaScriptの潤沢な資産を利用できるのです。

この章では発展編として、LocalStorageを用いたカート情報の永続化機能のコードを紹介します。Portを使用し、JavaScriptサイドとの型安全な連携がどのように表現されるかに注目してください。


状態操作に関する仕様が少々複雑になってきましたので、状態遷移図を用意し整理します。

4

状態遷移図だけでは、どのようなタイミングで状態が更新されるのかが分かりません。WebAPIの呼び出しや、JavaScript側へLocalStorageに対するデータ取得依頼が発生するタイミングを整理したシーケンス図を以下に示します。ElmからJavaScriptへ伸びる矢印のデータの流れがPortを用いる部分です。

5

以降は主要なコードの変更箇所を見ていきます。

Cartモジュールの変更箇所

LocalStorageと連携するためにPortをCartモジュール内に定義します。この部分の実装でポイントとなるのは、JavaScriptとの入出力に使われるデータの型をValue型としていることです。

-- ports


port persistOnLocalStorage : Encode.Value -> Cmd msg


port loadOnLocalStorage : () -> Cmd msg


port loadedOnLocalStorage : (Encode.Value -> msg) -> Sub msg

もちろんStringやIntなどのプリミティブ型をPort越しに入出力することも可能ですが、JavaScript側のデータ構造や型がElm側の定義と異なる際、ランタイムエラーとなってしまうデメリットがあります。これでは充分に型安全性が担保されているとは言えません。

ElmではPortにValue型を使うことで、JavaScriptとのデータのやり取りにデコーダとエンコーダを適用でき、型による安全性が担保されるのです。今回の実装では、専用のエンコーダとデコーダをローカルな関数として次のように定義しておきます。

-- internals


encode : ProductIds -> Encode.Value
encode productIds =
    Encode.list Encode.string (Set.toList productIds)


decode : Products.Products -> Decode.Decoder Cart
decode products =
    Decode.succeed (List.foldl (\id cart -> Tuple.first (add id products cart)) (Tuple.first empty))
        |> Pipeline.required "ids" (Decode.list Decode.string)

定義されたPort群は次にようにラップされた関数から呼ばれます。loaded関数はsubscriptionとしてAppモジュール側で呼び出される際に、外部から任意のMsgを渡せるようになっています。

loaded : Products.Products -> (Cart -> msg) -> msg -> Sub msg
loaded products onLoaded onLoadingFailed =
    loadedOnLocalStorage
        (\value ->
            value
                |> Decode.decodeValue (decode products)
                |> Result.map onLoaded
                |> Result.withDefault onLoadingFailed
        )


load : Cmd msg
load =
    loadOnLocalStorage ()

カートに商品を追加するadd関数と空のカートデータを生成するempty関数では、永続化を行うPortを呼び出します。

empty : ( Cart, Cmd msg )
empty =
    ( Cart
        { productIds = Set.empty
        -- ...
        }
    , Set.empty
        |> encode
        |> persistOnLocalStorage
    )


add : String -> Products.Products -> Cart -> ( Cart, Cmd msg )
add productId products (Cart cart) =
    let
        nextProductIds =
            Set.insert productId cart.productIds

        -- ...
    in
    ( Cart
        { productIds = nextProductIds
        -- ...
        }
    , nextProductIds
        |> encode
        |> persistOnLocalStorage
    )
Appモジュール

Modelには、LocalStorageからのCartデータの取得待ちの状態(LoadedProducts)表現するバリアントが増えました。商品一覧のデータのみがデータとして存在していることを表現するため、Products型のデータだけを持っています。

先の状態遷移図にあるとおり、LocalStorageからのデータが取得され次第LoadedAllへと遷移します。

type Model
    = Loading
    | LoadedProducts Products.Products
    | LoadedAll Session
    | Purchased Session

LocalStorageからのカート情報の取得に成功/失敗した際に呼ばれるMsgも増えました。

type Msg
    = ProductFetched (Result Http.Error Products.Products)
    -- ...
    | CartLoaded Cart.Cart
    | CartLoadingFailed

JavaScript側からの入力を受け付けるために、Cart側で定義したSubscriptionの関数をApp側で繋ぎこみます。

-- subscriptions


subscriptions : Model -> Sub Msg
subscriptions model =
    case model of
        Loading ->
            Sub.none

        LoadedProducts products ->
            Cart.loaded products CartLoaded CartLoadingFailed

        LoadedAll _ ->
            Sub.none

        Purchased _ ->
            Sub.none

Cartモジュール内で定義したPortはJavaScript側での実装が必要です。

const app = Elm.App.init()

app.ports.loadOnLocalStorage.subscribe(() => {
  app.ports.loadedOnLocalStorage.send(
    JSON.parse(localStorage.getItem("ids"))
  )
})

app.ports.persistOnLocalStorage.subscribe(ids => {
  localStorage.setItem("ids", JSON.stringify({ ids }))
}) 

Elmでは異なるプログラミング言語との連携において、FFI(Foreign Function Interface)のように言語の中に別の言語を埋め込むような手段をあえて採用していません。ElmにおけるPortも、JavaScriptの世界とElmの世界をつなぐPub/Subインターフェイスのように感じられます。

ここにはふたつの理由があります。ひとつは、やはり型安全性を担保することにあります。JavaScriptの世界と比べると、やはりElmの世界は方安全性が高いです。FFIを採用してElmの中にJavaScriptを書いてしまうと、結果的にElmの特性である型安全な世界が壊れてしまいます。Portというインターフェイスのみを経由してデータをやりとりし、Elm側ではデコーダのような仕組みを持つことで、安全性が守られているのです。

もうひとつは、Portの設計思想に影響を与えたポータビリティの考え方です。仮にElmが他の言語とのブリッジングを必要とするのであれば、ホストとなる言語はElmのPortのインターフェイスを実装するだけです。ElmはJavaScript以外の言語と将来的に相互運用できるようにも設計されているのです。

さいごに

今回の記事では初歩的なElmアプリケーションと、より実践的なアプリケーションのコードをご紹介しました。本稿を参考に、皆さんご自身がElmを使ったアプリケーション開発を楽しんでもらえたら幸いです。

今回のテーマとなったカートアプリケーションには、まだまだカスタマイズの余地があります。たとえば、今回の実装では、Cartモジュールにおける合計や小計などの数値の扱いがすべてIntで統一されているため、今後の国際化対応でドルによる計算などを考慮する場合、小数点が扱えるFloat型が必要になります。このような国際化対応を考慮するのであれば、新しく価格の計算ロジックや数値情報をカプセル化したOpaque Typeを導入するのが適切かもしれません。

他にもSPAのようなルーティングを実装してみたり、画面遷移を幽霊型でより堅牢な実装にするなど、Elmの特性を生かす余地はまだまだ残されています。ぜひ、GitHub上でフォークし、それを元にカスタマイズしElmを楽しんでみてください。

もっとElmを学ぶために

日本にはElm-jpコミュニティがあり、Elm公式ドキュメントの翻訳や、不定期でのミートアップの開催など国内で活発なコミュニティ活動が行われています。興味を持った方は気軽にDiscordサーバに参加してみてください。初心者向けの質問チャンネルもあります。

また、TwitterでElmのことをつぶやいたり、ご自分のブログやQiitaでElmに関する記事を書いてみるのも最高におすすめです。 国内でもじわじわElmの人気が高まってきていると感じますが、インターネットに存在するElm関連リソースの多くは、まだ英語です。些細なことでも、Elmに関する情報を日本語で発信することは、きっと多くのElm入門者たちの支えになるでしょう。私も皆さんのアウトプットを楽しみにしています!

泉 征冶 (いずみ・せいや) 6 @sy_izumi

7
Fringe81に2018年新卒で入社。以降、ピアボーナスプラットフォーム「Unipos」の開発に携わり、Scala、Golang、Elmに親しむ。2019年、パリで開催されたカンファレンス「elm Europe 2019」で日本人初の登壇を行う。
ブログ:Runner in the High
若手ハイキャリアのスカウト転職