Runner in the High

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

Elmにおける「型によるルールづけ」の考え方

Elmは静的型付言語なので、型のチカラを活かすことでコンパイラに「あっては行けない状態や組み合わせ」をチェックさせることができる。 高度な例としては前回書いた以下の幽霊型(Phantom Type)の記事があるが、もう少し簡単な例を紹介しようと思う。

izumisy.work

自作Error型の例

以下は自作のError型を定義した例だ。Error型はカスタムタイプになっていて、エラーのパターンを表現している。 それぞれのエラーはエラーのメッセージを持っていて、それらをtoString関数で取り出す事ができる

module Error exposing (Error, handle, toString)


type Error
    = Internal String
    | Connection String
    | Unknown String
   

type HandledError =
    HandledError String


handle : Error -> HandledError
handle error =
    case error of
        Internal msg ->
            HandledError msg

        Connection msg ->
            HandledError msg ->

        Unknown msg ->
            HandledError msg
            

toString : HandledError -> String
toString (Error msg) =
    msg

このモジュールにはルールがあり、メッセージをStringとして取り出す前には絶対にhandle関数が呼び出される必要がある。

したがって、Errorモジュールを呼び出すにあたっては、以下のようなコードはコンパイルエラーになる

import Error


-- ...


msg = 
    somethingReturningError -- Error型を返す関数
        |> Error.toString   -- Error型を文字列に変換したい(が、コンパイルエラーになる)

なぜなら、Errorモジュールの実装によればtoString関数は必ずHandleError型しか受け取れないからだ。

HandleError型はhandle関数を呼ばなければ受け取ることができない。 なので、以下のようにhandle関数の呼び出しを挟まなければtoString関数は呼び出せない。

import Error


-- ...

msg = 
    somethingReturningError -- Error型を返す関数
        |> Error.handle     -- Error型をハンドリング(←これが必須)
        |> Error.toString   -- Error型を文字列に変換したい

何がうれしいのか

この例は極端に簡潔な例で、別にhandle関数のcase...ofはtoString関数にあってもいいのでは? と思う人もいるのは納得できる。

しかし、上の例で伝えたい点は「型によってモジュールの使い方にルールを与えることができる」という点だ。例えば、Error型とは別にHandledError型がモジュールに用意されていることで、モジュール内部でHandledError型をとる関数というのは、必ずhandle関数を経由した上でしか呼ばれないということが自明になる。

関数シグニチャを見ることで、その関数がどのようなタイミングで呼ばれるかが分かるようになるし、コンパイラによるチェックも効く。そして、この仕組みをより高度に使った例が幽霊型(ファントムタイプ)だ。

実際のプロダクトにおいてどこまで型による制約を設けるかは堅牢性と複雑性のトレードオフになるが、型を自分たちのレールとして使うことの重要性は知っておいて損はない。