前回勉強してQiitaに投稿したCustomTypeとOpaqueTypeが実開発で活きたので、今回はその話を書きます!
題材となるものの仕様
図のような、年代別の出身国の人数をまとめたテーブルを表示するとする。
表を構成するデータは、以下の形でサーバから取得される。データの集計は全てサーバが行っており、フロント(Elm)はサーバから集計結果を受け取り表示するのみとする。
datas: [
{ age: "10代", country: "アメリカ", people_count: 6 },
{ age: "10代", country: "ブラジル", people_count: 13 },
{ age: "10代", country: "フランス", people_count: 7 },
{ age: "10代", country: "", people_count: 2 },
{ age: "10代", people_count: 28 },
{ age: "20代", country: "オランダ", people_count: 10 },
{ age: "20代", country: " 日本", people_count: 4 },
{ age: "20代", country: "", people_count: 1 },
{ age: "20代", people_count: 25 },
]
countryについて
サーバから受け取った生のデータは
-
country
というfield自体がない(その年代の「全体」を表すデータ) -
country
が空文字(出身国が「未設定」であるデータ) -
country
が国名を表す文字列
となっている。
elm-decode-pipelineのoptionalを使いデコード後に
-
"全体"
(optionalのdefaultValueを利用) -
""
(空文字) -
"アメリカ"
,"ブラジル"
...(任意の国名)
というように全てStringに変換する。
さらに空文字の場合は、「未設定」と表示するため変換する。
if country == "" then
"未設定"
else
country
リファクタ前のコードと問題点
country
の値は全てStringなので、最初Stringで定義してた。
type Data
= Data
{ age : String
, country : String
, peopleCount : Int
}
問題点1
- 全部Stringだけど、「全体」「未設定」「任意の国名」の3パターンに分類できることに気づいた。
- Stringという型定義は仕様に対してちょっと緩く、最適とは言えない。
=> これはCustumTypeの出番!?
問題点2
- いろんなところで
country == ""
やcountry == "全体"
といった比較表現が出てきてコードが煩雑になってきた。 - countryの内部実装を考えなくても、countryを呼び出したら、「全体」「未設定」「任意の部署名」のどれかのStringが返ってくるようになってたら、使いやすくていいんだけどなぁ。
=> これはOpaqueTypeの出番!?
リファクタ後のコード
Country.elm
type Country
= Country Kind
type Kind
= Total
| NotSet
| HasName String
new : String -> Country
new value =
if value == "全体" then
Country Total
else if value == "" then
Country NotSet
else
Country (HasName value)
isTotal : Country -> Bool
isTotal (Country kind) =
kind == Total
isNotSet : Country -> Bool
isNotSet (Country kind) =
kind == NotSet
name : Country -> String
name (Country kind) =
case kind of
HasName name ->
name
Total ->
""
NotSet ->
""
View.elm
"全体"と表示するか、"未設定"と表示するか、国名を表示するかはViewの責務なのでViewに以下のような条件分岐を持たせる。
if Country.isTotal data.country then
"全体"
else if Country.isNotSet data.country then
"未設定"
else
Country.name data.country
問題解決1
- 「全体」「未設定」「任意の国名」の3パターンがあることをコードで表現でき、より仕様に合った型定義になった。
- 型定義がより厳密になり安全になった。
- コードの読み手がコードから仕様をくみ取りやすくなった。
問題解決2
- 特に複雑なことを考えなくても、countryを呼び出せばパターン別に対応する文字列が返ってくるので使いやすくなった。
- Countryは内部実装はCustomTypeで実装されているが、それを知らなくてもまるでStringのように扱えるようになっている。
- isTotal, isNotSetといったAPIをCountryモジュールに持たせることで、data.countryの値を渡すだけで、実際にどんな文字列が入っているかを考えずに比較ができるようになった。
使う側の例1
div [] [text <| Country.toString data.country]
使う側の例2
-- data.country == "全体" みたいなのを書かなくてよくなった
if Country.isTotal data.country then
--
else
--
余談
今回は以下のような定義付けをした。
type Country
= Country Kind
type Kind
= Total
| NotSet
| HasName String
OpaqueTypeは必ず名前を同じにしなきゃいけない訳ではないが、名前を同じにする風習があるらしい。
また、CustumTypeも中の個々の型を公開しない(= Hoge(..)
の形で公開しない)のであれば、内部の型を隠蔽できているのでOpaqueTypeになっていると言える。
今回のCountry
も
type Country
= Total
| NotSet
| HasName String
と定義してCountry
だけ公開すればOpaqueTypeになるが、定義が抽象的すぎてしまうので、あえてKind
という要素を定義した。