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 -- これはコンパイルエラー!
)
UserId
はString
と異なる型になるためコンパイルエラーとして検出できるようになります。
しかし、所詮は型の別名に過ぎないことに注意が必要です。
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 にする
一方、より複雑な構造を持つ型を作成する場合は別の型と取り違える可能性が低いので レコード型でも十分候補になります 。そのためこちらの解説のようなもっと高度な考察をして判断する必要がありそうです。