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 RootType
と Path 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
というフィールドで、レコード型にすることによってRootType
とDocumentType
が部分的に一致する条件を作り出している。
Path型を受け取る関数では型変数へExtensible Recordを当てはめることで部分一致をチェックする。たとえば、runQuery
を実行する関数はQueriablePath
というExtensible Recordを受け取る型として指定することでRootType
とDocumentType
が受け取り可能になる。
runQuery : Decoder a -> Query.Query -> Path (QueriablePath b) -> Task Error (List (Query a))
このようにレコード型を幽霊型に用いると型レベルでのOR条件のようなものが表現できる。この手法を知っていればある程度複雑な型レベルでの状態遷移の仕様もコンパイラにチェックさせることが可能だし、特にFirestoreのパスビルダではまさにうってつけの方法だ。
デメリットがあるとすれば、やはり初見理解するのが難しいことと、コンパイルエラーがとてもじゃないがわかりやすいとは言えないところ。やっていることもまあまあ複雑なのでしょうがないと言えばしょうがないが。
なお、内部実装的には以下の記事で紹介している状態遷移パターンの実装とやっていることはほとんど同じである。 izumisy.work