Runner in the High

技術のことをかくこころみ

elm-firestoreにおける型安全なパス組み立ての設計

elm-firestoreのパス組み立て周りの実装を型安全な設計にしてみたのでその話。 github.com

Firestoreにおけるパスの仕様

まず大前提としてFirestoreにはリソースの場所を示すパスという概念があり、ざっくりパスは以下のようなルールに基づいている。

  • ルートにはコレクションのみが所属する
  • ドキュメントは必ずどこかのコレクションに所属する
  • コレクションはドキュメントの配下にも所属できる(サブコレクション)

つまり、必ずコレクションとその配下に複数のドキュメントがあり、ドキュメント配下ではまたコレクションを起点とした入れ子構造が形成される。例外的にルートには直接コレクションが所属するが、これはルート自体をひとつの更新/削除不可能な疑似ドキュメントであるとして考えればなんとなくしっくりくる。

elm-firestoreにおける表現

さて、上記の親子関係ルールに基づくパスの組み立て処理はelm-firestoreではパイプの構文を用いて以下のように表現される。

firestore
    |> Firestore.root                  -- ルート
    |> Firestore.collection "users"    -- コレクション
    |> Firestore.document "user1"      -- ドキュメント
    |> Firestore.subCollection "books" -- サブコレクション
    |> Firestore.document "book1"      -- ドキュメント

これが例えば、ドキュメントの配下にコレクションが来たり、サブコレクションの配下にルートが来たりするとコンパイルエラーがでる。

というのも、これらパスビルダ関数のシグニチャは次のようになっているからだ。Firestoreのパスが持つルールに基づいて、関数ごとにパイプでつなげる値の型が型変数で指定されている。

root : Firestore -> Path RootType

collection : String -> Path RootType -> Path CollectionType

document : String -> Path CollectionType -> Path DocumentType

subCollection : String -> Path DocumentType -> Path CollectionType

上記で使われているPath型の実装は以下。

type Path pathType
    = Path (List String) Firestore

この型が持つ pathType という型変数は幽霊型であり、この型でルート、コレクション、サブコレクション、ドキュメントの呼び出し関係をコンパイル時にチェックする仕組みになっている。

クエリ関数

ひとつ変わり種としてFirestoreには runQuery というオペレーションがある。これは少し特殊で、パス指定にはルートまたはドキュメントのみを指定する必要がある。それ以外のパスを指定すると、サーバーからエラーが返ってきてしまう。

上のパスビルダのシグニチャを考慮すると、関数のインターフェースとしては Path RootTypePath DocumentType の両方を受け入れるような設計にしたい。しかしElmにおいては型でそのようなORの構造は表現できない。

ではどうするかというと、レコード型を用いて呼び出し可能なオペレーションの許可リストのようなものを表現することで解決する。

type Specified
    = Specified


type alias RootType =
    { collectionPath : Specified
    , queriablePath : Specified
    }


type alias DocumentType =
    { documentPath : Specified
    , queriablePath : Specified
    }


--  いくつか本実装から省略...


type alias QueriablePath a =
    { a | queriablePath : Specified }

ここでポイントになるのは queriablePath というフィールドで、レコード型にすることによってRootTypeDocumentTypeが部分的に一致する条件を作り出している。

Path型を受け取る関数では型変数へExtensible Recordを当てはめることで部分一致をチェックする。たとえば、runQueryを実行する関数はQueriablePathというExtensible Recordを受け取る型として指定することでRootTypeDocumentTypeが受け取り可能になる。

runQuery : Decoder a -> Query.Query -> Path (QueriablePath b) -> Task Error (List (Query a))

このようにレコード型を幽霊型に用いると型レベルでのOR条件のようなものが表現できる。この手法を知っていればある程度複雑な型レベルでの状態遷移の仕様もコンパイラにチェックさせることが可能だし、特にFirestoreのパスビルダではまさにうってつけの方法だ。

デメリットがあるとすれば、やはり初見理解するのが難しいことと、コンパイルエラーがとてもじゃないがわかりやすいとは言えないところ。やっていることもまあまあ複雑なのでしょうがないと言えばしょうがないが。

なお、内部実装的には以下の記事で紹介している状態遷移パターンの実装とやっていることはほとんど同じである。 izumisy.work