Date from date components is incorrect after changing month

Hi! In my app, I faced with some problem:

When I change date components month from 12 to 1, resulting date will give the date of next year:
Dec 18 1960 will transform to Jan 18 1961, while date components would transform correctly

I found out that date components starting from December 18 affected by this issue
Why swift would do that?

var incorrectDate = Calendar.current.date(from: .init(calendar: .current, year: 1960, month: 12, day: 18))!
var correctDate = Calendar.current.date(from: .init(calendar: .current, year: 1960, month: 11, day: 20))!

var dateComponents = Calendar.current.dateComponents(in: .current, from: incorrectDate)

var dateFromComponents = dateComponents.date

dateComponents.month! = 1

var newDateComponents = dateComponents
var changedDateFromComponents = newDateComponents.date

I’m not sure I understand your description of the problem. My best guess is that the value you’re getting back in changedDateFromComponents is 1961-01-18 00:00:00 +0000 and you’re expecting it to be 1960-01-18 00:00:00 +0000. Is that right?

If so, just FYI, I can’t reproduce that here. I ran your code on my Mac (a command-line tool project created with Xcode 13.2 and run on macOS 11.6.1) and this line:

print(changedDateFromComponents?.description ?? "-")

printed:

1960-01-18 00:00:00 +0000

Still, that’s not super surprising. Your code has a lot of dependencies an external environmental factors — like the current locale, calendar and time zone — so it’s not uncommon to see behaviour differences like this.

IMPORTANT Speaking of context, this code is always wrong:

Calendar.current.date(from: .init(calendar: .current, year: 1960, month: 12, day: 18))!

The problem is that your date components are relative to the Gregorian calendar but Calendar.current may not be Gregorian. If the user has configured their Mac to use the Buddhist calendar, your date will be off by 500 years )-: If your code assumes Gregorian date components you must not use Calendar.current but instead hardwire the calendar to Calendar(identifier: .gregorian).


Coming back to your main problem, it’s hard to say what’s going on here without more details. However, I’m concerned that your calendar code is… well… frankly… kinda weird. I suspect you’re only hitting this oddity because your travelling down the wrong path. If you can explain more about your high-level goal, we should be able to get you on the right path.

Share and Enjoy

Quinn “The Eskimo!” @ DTS @ Apple

Hello, Quinn. Thank you for explanation.

My main goal is to create ClosedRange that contains range to check some other value in.

I've tried to hardwire both Calendar and TimeZone to Gregorian and UTC respectively. Still transforming incorrectly.

Here is updated code:

var incorrectDate = Calendar.init(identifier: .gregorian).date(from: .init(calendar: .init(identifier: .gregorian), timeZone: .init(identifier: "UTC")!, year: 1960, month: 12, day: 18))!
var correctDate = Calendar.init(identifier: .gregorian).date(from: .init(calendar: .init(identifier: .gregorian), timeZone: .init(identifier: "UTC")!, year: 1960, month: 12, day: 17))!

var startDateComponents = Calendar.init(identifier: .gregorian).dateComponents(in: .init(identifier: "UTC")!, from: incorrectDate)

var dateFromComponents = startDateComponents.date

startDateComponents.month! = 1

var transformedDateComponents = startDateComponents
var transformedDateFromTransformedComponents = transformedDateComponents.date!

var changedComponents = Calendar.init(identifier: .gregorian).dateComponents(in: .init(identifier: "UTC")!, from: transformedDateFromTransformedComponents)

//Passes
assert(startDateComponents.year == transformedDateComponents.year)
//Fails with incorrect date
assert(transformedDateComponents.year == changedComponents.year)

Just in case: currently I'm checking that in Playground

Hello, Quinn. Thank you for explanation.

My main goal is to create ClosedRange that contains range to check some other value in.

I've tried to hardwire both Calendar and TimeZone to Gregorian and UTC respectively. Still transforming incorrectly.

Here is updated code:

var incorrectDate = Calendar.init(identifier: .gregorian).date(from: .init(calendar: .init(identifier: .gregorian), timeZone: .init(identifier: "UTC")!, year: 1960, month: 12, day: 18))!
var correctDate = Calendar.init(identifier: .gregorian).date(from: .init(calendar: .init(identifier: .gregorian), timeZone: .init(identifier: "UTC")!, year: 1960, month: 12, day: 17))!

var startDateComponents = Calendar.init(identifier: .gregorian).dateComponents(in: .init(identifier: "UTC")!, from: incorrectDate)

var dateFromComponents = startDateComponents.date

startDateComponents.month! = 1

var transformedDateComponents = startDateComponents
var transformedDateFromTransformedComponents = transformedDateComponents.date!

var changedComponents = Calendar.init(identifier: .gregorian).dateComponents(in: .init(identifier: "UTC")!, from: transformedDateFromTransformedComponents)

//Passes
assert(startDateComponents.year == transformedDateComponents.year)
//Fails with incorrect date
assert(transformedDateComponents.year == changedComponents.year)

Just in case: currently I'm checking that in Playground

Hello Eskimo, thank you for explaining. Your guess is correct: I am expecting date in the same year.

My main goal to create ClosedRange of dates to detect some date in that range and I need to manually set date components for that.

I've tried hardwire both Calendar and Timezone to gregorian and UTC respectively, but still have this issue. Reproduced it in Playground and in XCTestCase

Here's updated version:

var incorrectDate = Calendar.init(identifier: .gregorian).date(from: .init(calendar: .init(identifier: .gregorian), timeZone: .init(identifier: "UTC")!, year: 1960, month: 12, day: 18))!
var correctDate = Calendar.init(identifier: .gregorian).date(from: .init(calendar: .init(identifier: .gregorian), timeZone: .init(identifier: "UTC")!, year: 1960, month: 12, day: 17))!

var startDateComponents = Calendar.init(identifier: .gregorian).dateComponents(in: .init(identifier: "UTC")!, from: incorrectDate)

var dateFromComponents = startDateComponents.date

startDateComponents.month! = 1

var transformedDateComponents = startDateComponents
var transformedDateFromTransformedComponents = transformedDateComponents.date!

var changedComponents = Calendar.init(identifier: .gregorian).dateComponents(in: .init(identifier: "UTC")!, from: transformedDateFromTransformedComponents)
// Passing
assert(startDateComponents.year == transformedDateComponents.year)
// Failing with incorrect date
assert(transformedDateComponents.year == changedComponents.year)
assert(startDateComponents.year == changedComponents.year)

The issue here is that Calendar.dateComponents(in:from:) is returning to you a DateComponents value with all fields filled. startDateComponents on my machine:

calendar: gregorian (fixed) timeZone: GMT (fixed) era: 1 year: 1960 month: 12 day: 18 hour: 0 minute: 0 second: 0 nanosecond: 0 weekday: 1 weekdayOrdinal: 3 quarter: 0 weekOfMonth: 4 weekOfYear: 52 yearForWeekOfYear: 1960 isLeapMonth: false

Note that this is specifying things like .weekday, .quarter, etc., which are specific to the returned date.

Changing just .month on those components means that all of the other fields may be now be out of sync: 1960-01-18 was not, for instance, yearForWeekOfYear: 1960, weekOfYear: 52, but yearForWeekOfYear: 1960, weekOfYear: 3.

When you request a date by calling transformedDateComponents.date!, the components you're using are inconsistent, and the results of the calendrical calculations depend on the order in which the components are attempted to be matched.

In the end, based on the order of calculations attempted, the next date matching some of the components you tried ends up being 1961-01-18.


If you want to perform an operation like this, you should either:

  1. Use a more explicit calendrical calculation method like Calendar.nextDate(after:matching:matchingPolicy:repeatedTimePolicy:direction) and pass in the minimal changed components + a direction you want to search in, or

  2. Specify only the components you want to grab from the calendar so that changing the .month no longer produces inconsistent components:

    var calendar = Calendar(identifier: .gregorian)
    calendar.timeZone = .init(abbreviation: "UTC")!
    
    var incorrectDate = calendar.date(from: .init(calendar: calendar, timeZone: calendar.timeZone, year: 1960, month: 12, day: 18))!
    print(incorrectDate)
    // => 1960-12-18 00:00:00 +0000
    
    var correctDate = calendar.date(from: .init(calendar: calendar, timeZone: calendar.timeZone, year: 1960, month: 12, day: 17))!
    print(correctDate)
    // => 1960-12-17 00:00:00 +0000
    
    var startDateComponents = calendar.dateComponents([.calendar, .timeZone, .year, .month, .day], from: incorrectDate)
    print(startDateComponents)
    // => calendar: gregorian (fixed) timeZone: GMT (fixed) year: 1960 month: 12 day: 18 isLeapMonth: false 
    
    var dateFromComponents = startDateComponents.date!
    print(dateFromComponents)
    // => 1960-12-18 00:00:00 +0000
    
    var transformedComponents = startDateComponents
    transformedComponents.month = 1
    
    var transformedDateFromTransformedComponents = transformedComponents.date!
    print(transformedDateFromTransformedComponents)
    // => 1960-01-18 00:00:00 +0000
    
    var changedComponents = calendar.dateComponents(in: calendar.timeZone, from: transformedDateFromTransformedComponents)
    print(changedComponents)
    // => calendar: gregorian (fixed) timeZone: GMT (fixed) era: 1 year: 1960 month: 1 day: 18 hour: 0 minute: 0 second: 0 nanosecond: 0 weekday: 2 weekdayOrdinal: 3 quarter: 0 weekOfMonth: 4 weekOfYear: 4 yearForWeekOfYear: 1960 isLeapMonth: false 
    

The downside to approach (2) is that you need to be very careful about the components you pull out: like @eskimo mentions, unless you hard-code your calculations to be in the Gregorian calendar, results may not be what you expect.

(In general, I’d recommend calling the appropriate methods on a calendar directly — it’s very easy to come up with nonsense date components, and calling .date on the components directly gives you no real way to affect results or recover reasonably.)

3 Likes

Great explanation.

This prompts the question of having a stricter version of date method, either the one that's optional, returns nil and prints what's wrong with the parameters to the console or the one that's throws and returns the corresponding error.

FWIW, DateComponents.date does already return a Date? (which goes along with DateComponents.isValidDate(in:) and DateComponents.isValidDate). Printing to the console is a bit tricky because it typically pollutes logs (and otherwise goes unread), and throwing an error could be slightly better, but isn't terribly useful at runtime (since you'd need to read the error message to figure out that the input was nonsensical).

It might be interesting to approach this using the same runtime warning system present in Xcode that the static analyzers use (I can't for the life of me remember what this feature is called — it's the integration that produces purple warning lines).

The console / throw error is secondary of course, once one knows there's a problem one can figure out what the problem is. What I was mainly pointing at was the date silently returning a valid date even when its components are inconsistent (and the result of that calculation is practically non-deterministic). I'd prefer having a stricter, say, strictDate (bike-shedding) that doesn't return a valid date (either by returning nil or by throwing an error or by crashing the app).

Ah, understood. Yeah, that could be another option.