LoginSignup
16
8

More than 3 years have passed since last update.

【Elm初心者】単純な型の実装方法を比較する

Posted at

Elmを完全に理解した初心者です。

Elmでは型を定義する方法が複数用意されています。

例として、認証機能を持つシステムのユーザーIDを考えてみます。
実質ただの文字列という 単純な構造だと選択肢が多くて 初心者は悩んでしまうでしょう。

今回はこの例について型の実装方法のパターンとその採用基準について考察しました。

1. プリミティブ型

まずはユーザーIDを単純にString型として扱うことが可能です。

最大のメリットは型として宣言する必要がなくて楽なことです。1つの小規模なモジュールでしか使われない型であればこれで十分です。しかし、ユーザーIDのようにシステムの核となるデータをStringで扱うのは得策ではありません。

たとえば以下のような型注釈を持つパスワードリセットメール送信関数を考えてみます。

sendPasswordReset : String -> String -> Cmd msg
sendPasswordReset userId email =
    ...

型注釈に2つのString型が出てきますが、実装部分を読まないとどのStringが何を表しているかがわかりません。これでは 可読性が低く、誤った値を関数に適用してバグになる可能性があります

2-1. 型の別名

型注釈をより明確にするには、型の別名 を使う方法があります。

type alias UserId
    = String

type alias Email
    = String


sendPasswordReset : UserId -> Email -> Cmd Msg
sendPasswordReset userId email =
    ...

String型に名前を付けることで 引数に意味をもたせる ことができました。

しかしこれだけでは不十分な点があります。別名を付けた型も実態はただのStringなので、以下のコードは問題なくコンパイルできてしまいます。

update : Msg -> Model -> ( Model, Cmd Msg )
update _ model =
    let
        notUserId : String
        notUserId = "simple string"
        notEmail : UserId
        notEmail = "abc123"
    in
    ( model
    , sendPasswordReset notUserId notEmail -- コンパイルできるよ
    )

型注釈が見やすくなったとはいえ、誤った値を関数に適用できる問題は残ったままです。

2-2. 型の別名 (レコード型)

「ただ1つのフィールドを持つレコード型の別名」として実装することもできます。

type alias UserId =
    { content : String }


type alias Email
    = String


update : Msg -> Model -> ( Model, Cmd Msg )
update _ model =
    let
        notUserId : String
        notUserId = "Simple string!"
        email : Email
        email = "mail@mail.com"
    in
    ( model
    , sendPasswordReset notUserId email -- これはコンパイルエラー!
    )

UserIdStringと異なる型になるためコンパイルエラーとして検出できるようになります。

しかし、所詮は型の別名に過ぎないことに注意が必要です。
Emailも同じフィールド名を使って以下のように書いたりすればUserIdと同じ型になってしまい、sendPasswordResetの第一引数にEmailを使えてしまいます。

type alias UserId =
    { content : String }


type alias Email =
    { content : String }


update : Msg -> Model -> ( Model, Cmd Msg )
update _ model =
    let
        notUserId : Email
        notUserId = { content = "mail@mail.com" }
        notEmail : UserId
        notEmail = { content = "abc123" }
    in
    ( model
    , sendPasswordReset notUserId notEmail -- コンパイルできるよ
    )

ここでは2つの型を並べて書いているので馬鹿みたいですが、別モジュールに書いていると気づかないかもしれません。

さらにこのパターンは、UserIdから.contentを使って中身を取り出したり、逆にラップしてやる手間が生じるので記述量が多くなるというデメリットがあります。

sendPasswordReset : UserId -> Email -> Cmd Msg
sendPasswordReset userId email =
    let
        userIdString = userId.content
    in
    ...

次の案と比較してもメリットが中途半端な感じなので、今回のようなフィールドが1つの場合は却下していいでしょう。

3-1. カスタム型

カスタム型を使うことで 他の型と取り違えられる問題を解決することができます

カスタム型にする場合の設計はこちらの記事が参考になります。

フィールドが1個だけならコンストラクタの書き方による記述量や関数の種類の違いは大差ないので、他のメリットを考慮してopaque type としてモジュールを作成しましょう。

module UserId
  exposing
    ( UserId
    , fromString
    , unwrap
    )


type UserId =
    UserId String


fromString : String -> UserId
fromString =
    UserId


unwrap : UserId -> String
unwrap (UserId string) =
    string

1つ前のパターンと同じく記述量が多くなるのでやたらと多用すると面倒になりますが、ユーザーIDであれば保守性のメリットが上回ると思います。

3-2. カスタム型 (レコード型?)

こんな書き方もできます。3-1と同様のメリットがありますが、記述量がさらに増えるだけなので却下でいいでしょう。

type UserId
    = UserId { content : String }

結論

単純な構造の型については以下のような結論になりました。
* 小範囲でしか使わない型はそのままプリミティブ型として扱う
* その中で型注釈を明確にしたいものは別名を付ける
* ○○IDみたいにシステム内で重要度の高い型は opaque type にする

一方、より複雑な構造を持つ型を作成する場合は別の型と取り違える可能性が低いので レコード型でも十分候補になります 。そのためこちらの解説のようなもっと高度な考察をして判断する必要がありそうです。

16
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
8