PureScriptで日付(Date)を扱うあれやこれや
PureScriptで日付を扱おうと思ったとき、日本語の情報が見当たらなかった(日付に限った話じゃないか)ので自分の備忘録も兼ねて書いてみることにしました。
さて、PureScriptで日付を扱うためにはDate
型を使うことになるかと思いますが、このDate
型はPureScriptらしくガチガチに型安全な感じになっています。
Date型の値を生成する
Date
型はデータコンストラクタがexportされていないので、値を生成するにはcanonicalDate
という関数を使うことになります。
canonicalDate :: Year -> Month -> Day -> Date
Int型の値から生成する
年月日ともにIntの値から生成する例です。
Maybe Date
型の値が返されます。
date :: Int -> Int -> Int -> Maybe Date
date y m d = canonicalDate <$> toEnum y <*> toEnum m <*> toEnum d
Maybe
が返されるのは、toEnum
がMaybe
を返すからです。
そしてtoEnum
を使っているのは、Year
型、Day
型はデータコンストラクタがexportされておらず、関数を使って値を生成する必要があるからです(Month
はデータコンストラクタがexportされていますが、この例ではtoEnum
を使っています)。
なぜこのようになっているかですが、各型に値として妥当な範囲が存在するものの、当然範囲外の値を渡すことができる(100月とか50日とか)ため、範囲外の値が渡された場合にNothing
と表現するためだと思われます。
Month
型の値を使用する)
Int型の値から生成する(月だけこちらは月だけMonth
型の値を使って生成する例です。
こちらもMaybe Date
型の値が返されます。
date :: Int -> Month -> Int -> Maybe Date
date y m d = canonicalDate <$> toEnum y <@> m <*> toEnum d
Month
型はJanuary
とかFebruary
とかデータコンストラクタがあるので、そちらを使うことができます。
Int型の値から生成する(Partial制約つき)
date :: Partial => Int -> Int -> Int -> Date
date y m d = fromJust $ canonicalDate <$> toEnum y <*> toEnum m <*> toEnum d
渡す年月日が妥当だと保証できる場合、必要に応じてunsafePartial
で制約を外すとよいでしょう。
Int型の値から生成する(範囲外の場合にデフォルト値を使う)
渡した年月日がそれぞれが範囲外の値であった場合、bottom
関数が返す値をデフォルト値として使う例です。bottom
は例えばDay
型の場合1になります。
date :: Int -> Int -> Int -> Date
date y m d =
canonicalDate
(fromMaybe bottom $ toEnum y)
(fromMaybe bottom $ toEnum m)
(fromMaybe bottom $ toEnum d)
文字列から生成する
2023/12/24
のように/
で区切られた文字列から生成する例です。
不正な値が渡される場合があるため当然Maybe Date
型が返されます。
parseFromString :: String -> Maybe Date
parseFromString s = case split (Pattern "/") s of
[year, month, day] -> canonicalDate <$> (convert year) <*> (convert month) <*> (convert day)
_ -> Nothing
where
convert :: forall c. BoundedEnum c => String -> Maybe c
convert = toEnum <=< fromString
文字列から生成する (JSのDateを利用してよろしくparseする版)
この例ではjs-date
パッケージの関数を使っています。
FFIしているため、Effect (Maybe Date)
が返されます。
Effect
にはなりますが、お手軽に文字列からDate
型に変換できます。
import Data.JSDate (parse, toDate)
dateFromString :: String -> Effect (Maybe Date)
dateFromString s = toDate <$> (parse s)
parse
はまんまJSのDateを使っています。
export function parse(dateString) {
return function() {
return new Date(dateString);
};
}
フォーマット
パターンを指定してフォーマットする
Date
型の値をフォーマットするには、formatters
パッケージのformatDateTime
関数を使うとよいでしょう。
import Data.Date (Date)
import Data.Either (Either)
import Data.DateTime.Instant (fromDate, toDateTime)
import Data.Formatter.DateTime (formatDateTime)
format :: Date -> Either String String
format = formatDateTime "YYYY/MM/DD" <<< toDateTime <<< fromDate
おまけ:Date型の定義はどうなってんの?
ここからは中身の話です。
Dateの定義
Date
型はdatetimeパッケージに入っており、定義はこうなっています。
-- | A date value in the Gregorian calendar.
data Date = Date Year Month Day
Year, Month, Day の定義
Year
,Month
,Day
は次の定義になっています。
newtype Year = Year Int
data Month
= January
| February
| March
| April
| May
| June
| July
| August
| September
| October
| November
| December
newtype Day = Day Int
上述の通り、Year
やDay
はデータコンストラクタがexportされておらずtoEnum
を使って値を生成していましたよね?
このあたりを見てみましょう。
Bounded
まずBounded
の説明をします。
Year
,Month
,Day
はBounded
という型クラスのインスタンスになっています。
コメントには「Bounded
型クラスは上下の境界を持つ完全順序型を表す」とあります。
Bounded a
の型変数a
はOrd
型クラスの制約を持ち、インスタンスはbottom <= a <= top
という条件を満たす必要があります。
このtop
とbottom
という関数は、くだけた言い方をすると境界の端っこの値を返す関数ということです。
(bottom
はDate
を生成する例でも出てきましたね)
class Ord a <= Bounded a where
top :: a
bottom :: a
Year
,Month
,Day
のインスタンスの定義を見てみます。
Month
とDay
の定義は境界としてわかりやすいですね。
月は1~12月までの範囲だし、日は1~31の範囲なので。
年がこのような境界になっているのはJSのdateに合わせてこうなっているようです。
instance boundedYear :: Bounded Year where
bottom = Year (-271820)
top = Year 275759
instance boundedMonth :: Bounded Month where
bottom = January
top = December
instance boundedDay :: Bounded Day where
bottom = Day 1
top = Day 31
ところでYear
とDay
に関しては、上記のように範囲が限定されるため、データコンストラクタがexportされていないのでした。
(Month
型は代数的データ型になっており、値が限定されているのでデータコンストラクタがexportされている)
そしてYear
とDay
の値を生成するにはBoundedEnum
型クラスのtoEnum
を使うのでした。
このtoEnum
を見ていきましょう。
toEnum
はa
型に変換できるときはJust a
を返し、できなければNothing
を返します。
class (Bounded a, Enum a) <= BoundedEnum a where
cardinality :: Cardinality a
toEnum :: Int -> Maybe a
fromEnum :: a -> Int
このtoEnum
のYear
, Month
, Day
の実装はこうなっています。
境界の範囲内であればJust
を返し、そうでなければNothing
を返しています。
instance boundedEnumYear :: BoundedEnum Year where
cardinality = Cardinality 547580
toEnum n
| n >= (-271820) && n <= 275759 = Just (Year n)
| otherwise = Nothing
fromEnum (Year n) = n
instance boundedEnumMonth :: BoundedEnum Month where
cardinality = Cardinality 12
toEnum = case _ of
1 -> Just January
2 -> Just February
3 -> Just March
4 -> Just April
5 -> Just May
6 -> Just June
7 -> Just July
8 -> Just August
9 -> Just September
10 -> Just October
11 -> Just November
12 -> Just December
_ -> Nothing
fromEnum = case _ of
January -> 1
February -> 2
March -> 3
April -> 4
May -> 5
June -> 6
July -> 7
August -> 8
September -> 9
October -> 10
November -> 11
December -> 12
instance boundedEnumDay :: BoundedEnum Day where
cardinality = Cardinality 31
toEnum n
| n >= 1 && n <= 31 = Just (Day n)
| otherwise = Nothing
fromEnum (Day n) = n
おわりに
コードを見ると、型安全になるよう工夫され、かつとても抽象化されているのが見て取れたと思います。
このような抽象的な部分を見ると感心してしまいますね。
Discussion