Typesafe unified Date and Time package using phantom record types

I first posted this idea half a year ago, finally got around to implementing it.

It’s common to have several different units of time in your app. You might have some timestamps that are in milliseconds or seconds, datepickers that are down to the day, and others that only let you select the month. If you’re doing scheduling you might also have hours and minutes.

You could use Time.Posix for everything, but that can lead to bugs if you for example store a date as “The first millisecond of the day”, and then when displaying it, you use the local timezone, which causes the date to jump forwards or backwards one day.

What we currently do in our app is use both Time.Posix and Date (justinmimbs/date) types. This works fairly well, although there is still the issue that there is no differentiation in type signatures between for example year+month and year+month+day. Also, packages tend to only work with one of them, for example ryannhg/date-format only works with Time.Posix, not Dates.

So here’s my attempt to solve this, using a single base type, parameterized with phantom record types, similar to how rtfeldman/elm-css works.

Internally everything is stored as Time.Posix. This saves on memory compared to a big record of Ints which was my first idea, and also lets me reuse all the functions from elm/time and justinmimbs/time-extra, so that I don’t have to worry about getting leap years etc wrong.

It’s still a prototype and needs refinement, testing and docs, so I haven’t published it yet. Here’s the repo, you can run it with elm reactor to play around.

I would very much like to get some feedback, if you see some issue with this approach.

Examples

This gives a type error, because you’re trying to display the day when it’s undefined:

DateTime.withYear 1989
    |> DateTime.withMonth Time.Aug
    |> Format.format
        [ Format.year
        , Format.text "-"
        , Format.monthNumber
        , Format.text "-"
        , Format.day
        ]

This works!

DateTime.withYear 1989
    |> DateTime.withMonth Time.Aug
    |> DateTime.withDay 30
    |> Format.format
        [ Format.year
        , Format.text "-"
        , Format.monthNumber
        , Format.text "-"
        , Format.day
        ]

This gives a type error, because you’re trying to display the hour, but you haven’t specified the time zone:

DateTime.withYear 1989
    |> DateTime.withMonth Time.Aug
    |> DateTime.withDay 30
    |> DateTime.withHour 13
    |> Format.format
        [ Format.year
        , Format.text "-"
        , Format.monthNumber
        , Format.text "-"
        , Format.day
        , Format.text " "
        , Format.hour
        ]

This works!

DateTime.withYear 1989
    |> DateTime.withMonth Time.Aug
    |> DateTime.withDay 30
    |> DateTime.withHour 13
    |> DateTime.withZone Time.utc
    |> Format.format
        [ Format.year
        , Format.text "-"
        , Format.monthNumber
        , Format.text "-"
        , Format.day
        , Format.text " "
        , Format.hour
        ]

This gives a type error, because dates shouldn’t care about zones:

DateTime.withYear 1989
    |> DateTime.withMonth Time.Aug
    |> DateTime.withDay 30
    |> DateTime.withZone Time.utc

This gives a type error, because you’re trying to get the difference in months, but one of them doesn’t have a month:

DateTime.diffMonths
    (DateTime.withYear 1989 |> DateTime.withMonth Time.Aug)
    (DateTime.withYear 2020)

This works!

DateTime.diffYears
    (DateTime.withYear 1989 |> DateTime.withMonth Time.Aug)
    (DateTime.withYear 2020)
18 Likes

Awesome code! I didn’t know that record types could be used that way.

I don’t quite follow this. Don’t dates always care about time zones? You say “2020-1-1” but that is a day just as dependent on zone as if you also had an hour. In the extreme case it would be two different days locally if you cross the date line.

@Jayshua If you have 2020-02-07 02:00 in UTC, then yes it should be 2020-02-06 21:00 in EST. And sometimes you may choose to display only the date part of a millisecond timestamp, and in that case timezones are also relevant.

But when you say “January 1st”, that’s January 1st all over time world, even if it doesn’t start at the same time. And when you want to “get all the reports for January 2020”, you don’t want that to be affected by the local timezone. See also justinmimbs/date

1 Like

This is great, thanks for making it. I tried to think of some gotchas, but it seems like it is sound.

I’m not a great fan of modelling a ‘date-only’ as a posix in UTC (in case someone doesn’t decode it in UTC) but the type checking would seem to prevent you doing silly things with it, so I guess its ok - if you encoded one as a string to send in a JSON payload for example you could only extract the d-m-y from it and not the underlying posix value.

Can you see how adding support for ‘time-only’ might work? Probably a separate type.

1 Like

Hello @Herteby, thanks for sharing this seems like a very useful tool!

I would love to have a package with a shared DateTime type definition that packages can depend on for the same reason that having common types like avh4/elm-color is useful as a type to share across packages.

Here are some examples where I have an API that accepts either a Date or a DateTime

In my RSS generator package I define a DateOrTime type which just wraps either Posix or Date: https://package.elm-lang.org/packages/dillonkearns/elm-rss/latest/Rss#DateOrTime. Here’s an example where I use that type:

I also have an API in the elm-pages SEO code that needs an ISO 8601 formatted date. I want it to accept a Date or Posix, but I resorted to the lame solution for now of taking in a String which I type alias to Iso8601DateTime, and just expecting the user to convert it themselves. I’ve just been waiting to get a nice shared type in a community package before doing that, because I wanted to avoid having a type to represent either Date or Posix in every single package that uses that type. Here’s that API https://package.elm-lang.org/packages/dillonkearns/elm-pages/latest/Head-Seo#article.

I would much prefer to use a shared community type for these APIs, so I will definitely change it out to use the type from your package once you publish it!

Suggestion for your API

The one thing I would suggest for your API that would be helpful for my use cases is I think it would be nice to have a fromDate helper that takes in justinmimbs/date Date value, similar to the fromMillis and fromPosix functions you define here:

I know that it would introduce an extra dependency, but I think users’ code will often store their data as a Date value (when accurately models their data), and only convert it to the DateTime type defined by your package once it is passed to another API, like my RSS package or my elm-pages SEO API. Since your DateTime type is “lossy” in a way (you lose the type information that the value was originally stored as something representing only a date, not a date with a time specified), so it wouldn’t make sense for users to store it as that type in their own code.

Would love to see this published, thanks again for working on this project.

1 Like

Hmm, I think maybe it could even be the same type, will see if it can work without losing the safety.

That sounds great! :grinning:

I would like to avoid having it as a dependency, but I was thinking of including a fromRataDie : Int -> DateTime Days function so that you can just do Date.toRataDie >> DateTime.fromRataDie.

No actually the whole idea of this package is to keep track of which unit of time it is, and to keep your types specific! DateTime Days for example means that it has year, month and day, and no time.

1 Like

I like that idea a lot! Decoupling from that dependency is definitely nice when it comes to avoiding dependency updates and mismatches and the complications that come with that.

Clever! I missed that detail, thanks for clarifying. That’s really neat how you can use the phantom types to store the data in a simple type and yet have a lossless format for simple Dates. I’m looking forward to using this package!

Awesome idea and use of phantom types!
I’m confused by withSameZone, what does it mean?
Why do the addX methods require a zoneless DateTime but the diffX do not?

One thing that doesn’t convince me is that in withTimezone you’re not saving the timezone, but applying an offset. I think there might be some edge cases where this is not correct, around the DST change, but I’d have to make some tests to be sure either way.

If you haven’t done so already, I really suggest you take a read at the difference between Interval and Period in Joda Time, it might inform some API choices.

Big congratulations in any case, time is hard.

2 Likes

The idea was that you can use it if the time is actually already in a local timezone, rather than UTC. Not really sure I will leave it in though.

The time zone stuff is definitely what I’m least sure about, and was kind of something I just threw in to get things working. It may very well need to change completely!

One weird thing with the current diffX functions is that you can compare DateTimes with different timezones, and the time zone offset will be included in the difference, which is probably not what you want.

I’ve heard Joda Time is supposed to be pretty good but… 46 Classes and 11 Interfaces for time! :scream: I wonder how many individual methods that is, 500? 1000?

This is also something to consider…

But that does not hold. If I ask for the January report, there is an implicit time zone, probably defined by some business rules. Say I want a list of transactions in January, that could mean January in another time zone than my current one.

Maybe it just me, but I can not see a use case for communicating with a user about time without having a time zone. Which one, is usually the problem.

I wrote something before about when you do and don’t want to make use of timezones:

[How to get the delta value from Time Zone - #9 by rupert]

Here an the example from that, when timezone is irrelevant:

"You go on holiday and book a hire car to collect at the airport. You know your flight is due to get in at 11 and its going to take a while to get out of the airport so you put 11:30 as the collection time for the car. It doesn’t really make sense to translate this into UTC as it is a local event only. If you translate it into UTC and email the user to remind them of the pick up time in their home local time the message might say “remember your car booking at 03:30”. In that case you probably don’t even want to use Time.Posix although it does not contain the timezone so can be used for local times. I would just create my own data type to hold the local times for this application:

type alias LocalTime = 
    { hour : Int
    , minute : Int } -- That's all that's really needed for the car rental app.

"

This also explains why I am not a fan of using Posix without a timezone; the default timezone is UTC and that might get added back into something that does not have a timezone; the Posix is easy to convert into other timezones not intended with erroneous results.

It depends on exactly what we are talking about, but I would think that a January report for a business (or more likely a quarterly report), would not be a timezone dependent thing. Even if it were a multinational say, you would just add up the turnover/profit/whatever across all worldwide subsidiaries from the 1st to the 31st of January in each of their respective local dates. It is still meaningful to speak of the month of January, even though its exact start and end moments are not singularly defined concepts.

1 Like

Looks really cool!

Have you had any ideas about how to handle formatting and i18n/localization? E.g. formatting the month as a string, allowing for ordinal indicators (th/rd/nd). On a related note, it might also be good to have a way to format the day number without the leading ‘0’.

I was thinking of reusing one of the language formats from miniBill/date-format-languages. And yup, more format functions will be needed.

2 Likes

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.