⚗️

elm-deriveでElmのJSONエンコーダー/デコーダーなどを自動生成する

2021/08/08に公開

Elmで初心者がつまづきやすいポイントに、JSONデコーダー/エンコーダーがあります。エンコーダーはともかく、デコーダーは入力 -> 出力 という関数の形になっていなくて動作が微妙にイメージしづらいですし、Elmの型システムにある程度習熟していないとコンパイルエラーを理解するのが難しいと思います。

とはいえ、ElmのJSONデコーダー(Json.Decode.Decoder)の定義はとても規則的なものなので、慣れれば居眠りしながらでも書けるような単純なものだったりしますし、それはそれで慣れてくるととても面倒くさく感じたりします。そこで、あくまで実験的にですが、Elmのデータ型の定義からJSONデコーダー/エンコーダーを自動的に生成するツール elm-derive を作ってみました。

つかいかた

ブラウザで動くGUIとCLIを用意してあります。手軽に遊ぶには https://elm-derive.netlify.app/ をブラウザで開いてみてください。デモページを開くと、サンプルとしてTodoアプリを表すデータ型が用意されています。

使い方としては、ページ左側のテキストエリアにElmのソースコードを入力すれば、ページ右側に生成されたコードが表示されます。また、右上のチェックボックスで、5種類のコードのうちどれを生成するかを設定できます。適当にいろんなデータ型を入力して試してみてください。

elm-deriveが生成するのはJSONデコーダーだけではなく、任意のデータ型 a に対して次の5種類の関数や変数を生成できます。

  • JSONデコーダー Json.Decode.Decoder a
  • JSONエンコーダー a -> Json.Encode.Value
  • 乱数ジェネレータ Random.Generator a
  • HTMLのビュー a Html.Html msg
  • 比較関数 a -> a -> Order

なお、生成元のデータ型 a は、次のようなプリミティブなデータ型の組み合わせになっている必要があります。

  • プリミティブ型 Int / Float / String / Bool / Char
  • カスタム型 / レコード型 / 2要素タプル (t, s) / ユニット型 ()
  • List t / Array t / Set comparable / Dict String t
  • Maybe t / Result err ok

いまのところ、任意の型変数を持つデータ型 m a にはすべて対応していなくて、対応しているのはList aなどの既知の基本的なデータ型だけです。また、複数のモジュールにまたがって定義されたデータ型には対応していません。そのあたりは頑張れば技術的には実装できると思います。

elm-deriveの仕組み

elm-deriveでやっていることは単純で、stil4m/elm-syntax を使って入力されたElmモジュールを構文解析して、the-sett/elm-syntax-dsl でElmコードを気合で生成しています。基本的には、構文解析して見つけたデータ型を片っ端から対応するデコーダー置き換えているだけです。

例えば、Elmのコード中に

foo: Bool

という型注釈があった場合、stil4m/elm-syntax でそのコードを構文解析すると 、Boolの部分はTyped (Node _ ( [], "Bool" )) []というノードになります。そして、次の generateDecoderFromTypeAnnotation のような関数では、このTypeAnnotationをパターンマッチングして、対応するデコーダー Json.Decode.boolに対応するノード fqVal [ "Json", "Decode" ] "bool"を生成しています。

generateDecoderFromTypeAnnotation : File -> TypeAnnotation -> Result Error Expression
generateDecoderFromTypeAnnotation file typeAnnotation =
    case typeAnnotation of
        Typed (Node _ ( [], "Bool" )) [] ->
            Ok (fqVal [ "Json", "Decode" ] "bool")

このように、もとのコード中から構文解析して見つけた型注釈をひたすらパターンマッチングしては、対応するデコーダーやエンコーダー、乱数ジェネレータなどに置き換えているだけです。

なお、stil4m/elm-syntax でもコードの生成自体はできるのですが、コード生成時には不要なトークンの位置情報をいちいち挟まないといけないのが面倒で、それをラップして使いやすくしてくれている the-sett/elm-syntax-dsl を使っています。

なお、elm-deriveで自動生成されたJSONデコーダー/エンコーダーが正しいかどうか確認するために、データ型からelm-deriveで生成したコードを使ってランダムなデータを自動生成し、そのデータをelm-deriveで自動生成したエンコーダーでエンコード、そこから自動生成したデコーダーでデコードして、もとのデータに戻っているかどうか、というテストをしていたりします。

コード生成の限界

たとえば、トランプのゲームを作っているとして、スーツを表す次のようなデータ型があるとしましょう。

type Suit = Spade | Heart | Diamond | Club  

このカスタム型は次のようなJSONエンコーダを生成します

encodeSuit : Suit -> Json.Encode.Value
encodeSuit val =
    case val of
        Spade ->
            Json.Encode.object [ ( "$", Json.Encode.string "Spade" ) ]

        Heart ->
            Json.Encode.object [ ( "$", Json.Encode.string "Heart" ) ]

        Diamond ->
            Json.Encode.object [ ( "$", Json.Encode.string "Diamond" ) ]

        Club ->
            Json.Encode.object [ ( "$", Json.Encode.string "Club" ) ]

これは、TypeScriptでいうと次のような型のJSONデータを生成しているわけです。

type Suit = { $: "Suit" } | { $: "Suit" } | { $: "Suit" } | { $: "Suit" }

しかし、このような場合、TypeScriptでは次のようにただの文字列として使いたい場面のほうが多いように思います。

type Suit = "Suit" | "Suit" | "Suit" | "Suit" 

カスタム型のヴァリアントは複数個のフィールドを持つことがあるので、たとえフィールドを持たないヴァリアントでもオブジェクトとしてエンコードしているわけです。もちろん、フィールドを持たないヴァリアントは例外的にただの文字列としてエンコードする手もあるのですが、その場合はデコーダーが複雑になってくるので、一貫性を優先してこのようにしています。

また、将来的にデータ型が変更されることも多いですが、elm-derive は完全に機械的にデコーダー/エンコーダーを生成してしまうため、古いデータ構造で生成されたデータはうまく読み取れません。その場合は以前のデータを読み取れるように結局手作業でデコーダーを改修する必要があります。そんなわけで、elm-deriveのようなツールによるコードの自動生成が現実の開発作業でどれだけうまく機能するかには、かなり疑問が残ります。

冒頭で言ったように、JSONデコーダー/エンコーダーは居眠りしながらでも書けるほど原理は単純なので、自動生成を頑張って工夫するよりは手動で書いたほうが柔軟に対応できたりします。手作業で書いてもせいぜい10分や20分余計な時間がかかるだけで、ここを自動生成しても大幅な効率化にはならないはずです。もちろん、あとで捨てる前提のプロトタイプを手早く作りたいときや、初心者の学習用としては、elm-deriveのようなツールも役に立つかもしれません。

さいごに

stil4m/elm-syntax もいろいろと使いみちが広いパッケージだと思います。Elmはメタプログラミングの機能がぜんぜんない言語ですが、Elmでできる数少ないメタプログラミングの手法として、stil4m/elm-syntax による構文解析と the-sett/elm-syntax-dsl を使ったコード生成を試してみると夢が広がると思います。

Discussion