[Pitch] Extending Date with methods to get specific new dates

Motivation

I often see myself needing e.g. the first or last day of a month or a year for generating statistics or e.g. EOM trigger.
In Swift, I find such date handling pretty complicated, as you have to use Calendar and DateComponents to get the wanted behavior which always requires multiple lines of code. While this can be worked around with using extensions (which I use as a standard in all my projects) I think it would be nice to see such features integrated into Foundation.

Proposal

For this reason I propose adding methods to the Date type to shortcut such behavior.
While this is a pitch, and and detailed implementation is of course open to discussion I was thinking of adding something like

let midnight = Date().date(second: 0, minute: 0, hour: 0) // 05.07.2024 00:00:00
let firstDayOfMonth = Date().date(day: 1) // 01.07.2024…
let firstDayOfYear = Date().date(day: 1, month: 1) // 01.01.2024…
…

All the parameters should have a default value for the current date, which would mean that, at least internally, DateComponents like day need to be directly accessible via date, but something like

let day = Date().day // 1

would also be nice to have publicly exposed, if the easiest implementation of my idea would require it anyway.

Internally, it can still use Calendar and DateComponents, per default I would suggest using Calendar.current but it is a parameter, to customize the Calendar, if the user desires so.

This pitch would make some methods inside DateComponents obsolete, but for backwards compatibility, I would not remove these methods.

1 Like

While introducing Calendar and DateComponents do make the expressions more cluttered, they also make them more correct.

A Date has no hour, minutes seconds. No day, month, year.

A date is a point in time. How we name that point-in-time, or how we refer to it, or its relation to other points-in-time, are dependent on context. You may think it obvious which time zone and calendar system constitutes the context in your particular situation, but these are not universal.

Think of some universal event. Say, the exact moment that the earth's tilt towards the sun peaked (aka, summer solstice which was fairly recent). This moment was an exact point-in-time. Where I live, we name that moment Thursday June 20th 2024, 22:50. However, in other locations on earth, this happened on a Friday.

And this is just base don time zones. We also have daylight savings, different calendar systems. And even within the same calendar systems, we have different starts-of-week, different adoption times, and other "calendrical dialects".

16 Likes

No, this should not happen, for the exact same reason you would not extend CLLocationCoordinate2D to have a var street: String property.

Date is a temporal coordinate. Its only unit is SI Seconds (which are not the same thing as calendar seconds!) and has no notion of any human construct like days, months, years, etc.

Correct approaches to this problem always involve a Calendar and a TimeZone, and I strongly encourage everyone working in this domain to build human-comprehensible abstractions over Date (or if you don't want to do it yourself, use mine!).

24 Likes

Such a great analogy! :clap:t2: best description I’ve ever read.

2 Likes

+1 to this proposal. The native calendar-based API is bulky and non-intuitive. I find the arguments in previous comments to be an overcomplication of a simple concept, based more on personal feelings than objective reasoning.

For example, to get a date one day later than a given date, we can do:

  1. Using a calendar or clock instance:
// native way
date = Calendar.current.date(byAdding: .day, value: 1, to: givenDate)
  1. Using an extension on Date as proposed:
extension Date {
   func adding(_ component: Calendar.Component, _ value: Int = 1, calendar: Calendar = .current) -> Date?
}
...
date = givenDate.adding(.day)
  1. Using a global function:
func add(_ component: Calendar.Component, _ value: Int = 1, to date: Date, calendar: Calendar = .current) -> Date?
...
date = add(.day, to: givenDate) // or even something like `date + 1.days`, but it loses flexibility

The first method is considered correct, while the second is not, because the date itself is not responsible for the human interpretation of time.

In my opinion, all these methods are essentially the same, offering the same functionality with equal flexibility, differing only in syntax. I personally prefer the shortest and most human-like syntax, favoring the second or third method, but definitely not the first. So, +1 to the proposal. I also have a library with frequently implemented Date extensions that may be useful to someone.

PS: You can criticize me, but CLLocationCoordinate2D().street looks absolutely fine to me, very convenient.

-1.
The extensions you mention probably work, but they only really work for you, with defaults relevant to your own requirements. They most likely will not be relevant for the rest of us.

7 Likes

Whether or not one disagrees with whether Date is the right place to put a method like this, functionally, the issue with an extension like this is hiding the calendar from the operation. Specifically, an operation like this only makes sense within the context of a specific calendar, and Calendar.current is very often not the calendar you want to use for calendrical operations. A lot of calendar math is very contextual, and not all users use the Gregorian calendar; assuming this leads to very confusing behavior and breakages you might not know how to deal with.

It's very important to not provide a default value for calendar here, so you can clearly see at the callsite which calendar you're using for the operation. Sometimes you do want Calendar.current, but very often you want a specifically-defined Gregorian calendar, which may or may not be set to the user's time zone (sometimes you want to set the calendar to UTC for interoperation with external sources).

Whether you prefer something like

date.adding(1, .day, in: gregorianCalendarInLocalTimeZone)

over

gregorianCalendarInLocalTimeZone.date(byAdding: .day, 1, to: date)

is then a more personal preference.


i.e., what @davedelong said :+1:

12 Likes

I find interactions with date APIs in languages, where calendar context is hidden away, way more complicated. You simply can't understand in which context you operate currently, as different languages also choose different defaults.

Foundation's APIs for date manipulations might feel annoying at first, as you forced to write much more in order simply advance date, but it make a lot of sense once you get used to it, and then start appreciate this verbosity. I've been building comprehensive calendar feature in the app, using tons of these APIs, and of course had to support different calendars. This explicit requirement for calendar helped in many cases to think of more robust solution and avoid issues in the first place; and even if someone was not familiar with these APIs before, this verbosity gave them much better mental model of what's going on.

2 Likes

One of the strengths of extensions is that you can create the exact convenience methods you want or need in your project(s). Adding conveniences to the framework itself requires baking in a lot of assumptions.

For example, in the example provided, the default calendar is current and not autoupdatingCurrent.

extension Date {
   func adding(_ component: Calendar.Component, _ value: Int = 1, calendar: Calendar = .current) -> Date?
}

If my project uses autoupdatingCurrent by default then I would probably end up trying to write my own convenience method that wraps this convenience method.

However, my convenience method would conflict with the framework extension method because of the default parameter values. The method in the framework prevents me from providing my own similar method that uses the defaults appropriate for my project.

One of the main reasons convenience methods are convenient is because they require fewer parameters to be specified.

Adding conveniences to the framework needs to bake in a lot of assumptions that are not necessarily true in many use cases and potentially hinder people from writing their own convenience methods that match their use case.

I believe the current approach of having the framework provide the primitives but having the flexibility to create convenience methods, either in a separate package or in individual projects is an excellent balance.

It provides correctness, completeness, and flexibility for all users of the framework but allows relatively easy, often one- or two-line customizations for convenience for specific projects.

6 Likes

Yes, there is no global default Calendar suitable for all cases; I used .current just as an example and didn't focus on it.

It's highly unlikely that you will specify a different calendar for each date operation. In most cases, we need one calendar for a bulk of operations or even one per project (a common situation for client applications). I find it unreasonable to force everyone to specify a calendar everywhere, just because sometimes it should be customized or highlighted.

For my use, I implemented a global Calendar.default computed property that can be bootstrapped globally or locally via TaskLocal like this:

// Set a global default computed value with a task local calendar
Calendar.bootstrap(default: .local)
// Use it implicitly
let today = Date.now.start(of: .day)
// Override it locally
Calendar.$local.withValue(gregorianCalendarInLocalTimeZone) {
  let today = Date.now.start(of: .day)
  ...
}
// Specify a calendar for one method
let customToday = Date.now.start(of: .day, calendar: oneTimeUsedCalendar)

Perhaps my experience as a client-oriented developer influences my perspective, but Calendar information is not essential for understanding how the code works in most cases, except for such where it is customized.

So probably instead of extending Date we can try to came up with some wrapper types for common operations that logically belong together. And those types will require calendar to instantiate them? I think that might be a direction that will keep the best from both worlds.

1 Like

While I still think that Date handling syntax is a pain, I now also see that there are valid reason for this, which may not apply to my usecases, but for sure to the usecases of others.

Maybe something like this should then be build into Foundation. It would take away parts of the complexity of date handling and could clearly be marked as the preferred way to handle (human readable) dates as it secures against common pitfalls. And I think it’s much nicer than DateComponents which sometimes have unexpected results (at least for me) as the computer has different unterstanding of dates then me ;)

Humans have different, mutually incompatible expectations of date handling, different from computers and different from each other. Foundation does not "take away parts of the complexity" because it is essential for end users to express their expectations in order to (possibly) get expected results.

You can build or adopt your own abstractions that fit your own expectations, and Swift makes it easy to do so because types can be extended. But because one person's "common pitfall" is another person's expected result, blessing one expectation will not erase "unexpected results" for everyone or even most people. Instead, it will just hide them.

10 Likes

Calendar does have some of this kind of convenience API, and I think is the right home for more of it -- if we find useful methods.

For example, we already have:

    /// Returns the first moment of a given Date, as a Date.
    ///
    /// For example, pass in `Date()`, if you want the start of today.
    /// If there were two midnights, it returns the first.  If there was none, it returns the first moment that did exist.
    /// - parameter date: The date to search.
    /// - returns: The first moment of the given date.
    @available(iOS 8.0, *)
    public func startOfDay(for date: Date) -> Date

And the implementation is pretty straightforward too. It is directly extensible to month or year.

To @itaiferber's point, since this is on Calendar there is less possibility of confusion about what kind of human calendar you want.

I'm not opposed to adding useful utilities like this to Calendar when they help to reduce confusion. We do need to be careful not to oversimplify a complex problem, or introduce a lot of duplicated ways to do the same thing. Getting the start of a day was common enough to put the convenience in, but it's unclear if we need to extend that to months, weeks, days, hours, or the rest of the components possible to use with dateInterval.

5 Likes

One more thing -- the new dates(byMatching:...) API may be a really good candidate for this kind of use case (generating points in time to bucket statistical data), since it's a Sequence type.

The proposal is here.

1 Like

To further drive home the point, it’s important that this convenience method is on Calendar and not Date because the Hebrew calendar considers the day to have started at sunset.

1 Like

This is perhaps true culturally, but it is not how ICU implements the algorithms.

import Foundation

let g = Calendar(identifier: .gregorian)
let h = Calendar(identifier: .hebrew)

let now = Date()

let gregorianStart = g.startOfDay(for: now)
let hebrewStart = g.startOfDay(for: now)

print(gregorianStart == hebrewStart) // true

(it's hard to do sunrise/sunset-based calendars because in addition to tracking time zone, you also have to start tracking latitude and longitude so you can calculate sunrise/sunset times. And even that wouldn't account for local geography, which can influence observance times)

2 Likes

Clearly we just need to add coordinate and topographicMap properties to Calendar :slight_smile:

3 Likes

I know you’re joking, but Earth’s behavior is too variable to extrapolate mathematically from such a property. It’s also not compatible with observation-based calendars such as the Islamic religious calendar. I suspect the only solution for such calendars would be to carry a table of data as is done for eras in the Japanese calendar.

4 Likes

It would be great if Swift would allow us to easily compose types which is different from traditional inheritance.

What I mean by that is something that Go language is allowing.

e.g. you can create a new type very cheaply:

type Rectangle struct {
  Length float64
  Width float64
}

type Square struct {
  Rectangle
}

This way without boilerplate you get the entirely new type Square that is composing/embedding with Rectangle which gives the Square type all the methods/properties that are available in the Rectangle type.

I believe this concept would fulfill this pitch but open to many more ideas.

In terms of Date type I would e.g. very easily could create TimeZonedDate type that would be composed with the Date.

But we could imagine that this could be used for many other use cases, like struct that could be composed with each other without boilerplate.
You could imagine composing structs as needed in the case of Codable.