📅

PureScriptで日付(Date)を扱うあれやこれや

2023/12/24に公開

PureScriptで日付を扱おうと思ったとき、日本語の情報が見当たらなかった(日付に限った話じゃないか)ので自分の備忘録も兼ねて書いてみることにしました。

さて、PureScriptで日付を扱うためにはDate型を使うことになるかと思いますが、このDate型はPureScriptらしくガチガチに型安全な感じになっています。

Date型の値を生成する

Date型はデータコンストラクタがexportされていないので、値を生成するにはcanonicalDateという関数を使うことになります。

Data.Date
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が返されるのは、toEnumMaybeを返すからです。
そしてtoEnumを使っているのは、Year型、Day型はデータコンストラクタがexportされておらず、関数を使って値を生成する必要があるからです(Monthはデータコンストラクタがexportされていますが、この例ではtoEnumを使っています)。

なぜこのようになっているかですが、各型に値として妥当な範囲が存在するものの、当然範囲外の値を渡すことができる(100月とか50日とか)ため、範囲外の値が渡された場合にNothingと表現するためだと思われます。

Int型の値から生成する(月だけMonth型の値を使用する)

こちらは月だけ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を使っています。

JSDate.js
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パッケージに入っており、定義はこうなっています。

Data.Date
-- | A date value in the Gregorian calendar.
data Date = Date Year Month Day

Year, Month, Day の定義

Year,Month,Dayは次の定義になっています。

Data.Date.Component
newtype Year = Year Int

data Month
  = January
  | February
  | March
  | April
  | May
  | June
  | July
  | August
  | September
  | October
  | November
  | December

newtype Day = Day Int

上述の通り、YearDayはデータコンストラクタがexportされておらずtoEnumを使って値を生成していましたよね?

このあたりを見てみましょう。

Bounded

まずBoundedの説明をします。
Year,Month,DayBoundedという型クラスのインスタンスになっています。

コメントには「Bounded型クラスは上下の境界を持つ完全順序型を表す」とあります。

Bounded aの型変数aOrd型クラスの制約を持ち、インスタンスはbottom <= a <= topという条件を満たす必要があります。
このtopbottomという関数は、くだけた言い方をすると境界の端っこの値を返す関数ということです。
bottomDateを生成する例でも出てきましたね)

Data.Bounded
class Ord a <= Bounded a where
  top :: a
  bottom :: a

Year,Month,Dayのインスタンスの定義を見てみます。
MonthDayの定義は境界としてわかりやすいですね。
月は1~12月までの範囲だし、日は1~31の範囲なので。
年がこのような境界になっているのはJSのdateに合わせてこうなっているようです。

Data.Date.Component
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

ところでYearDayに関しては、上記のように範囲が限定されるため、データコンストラクタがexportされていないのでした。
Month型は代数的データ型になっており、値が限定されているのでデータコンストラクタがexportされている)

そしてYearDayの値を生成するにはBoundedEnum型クラスのtoEnumを使うのでした。

このtoEnumを見ていきましょう。

toEnuma型に変換できるときはJust aを返し、できなければNothingを返します。

Data.Enum
class (Bounded a, Enum a) <= BoundedEnum a where
  cardinality :: Cardinality a
  toEnum :: Int -> Maybe a
  fromEnum :: a -> Int

このtoEnumYear, Month, Dayの実装はこうなっています。
境界の範囲内であればJustを返し、そうでなければNothingを返しています。

Data.Date.Component
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