let formatter = ISO8601DateFormatter()
formatter.timeZone = TimeZone(identifier: "Europe/Stockholm")
formatter.formatOptions = [.withFullDate]
// 2023 is not a leap year, so only 28 days in February, yet this won't crash:
let date = formatter.date(from: "2023-02-29")!
print(formatter.string(from: date)) // Prints 2023-03-01
Even "2023-02-31" is accepted (as a date which the formatter will format as "2023-03-03").
But, as expected, "2023-02-32" is not accepted, and neither is "2023-13-01" or "2023-00-01".
So if I want to verify that someDateInput is a valid date string, then I can't just do formatter.date(from: someDateInput) != nil. I have to do something like:
guard let date = formatter.date(from: someDateInput),
formatter.string(from: date) == someDateInput
else {
return false
}
return true
I think the formatter is just checking that the string has a day value in the valid range of days (1-31), and then when it finds out that there aren't enough days in February to actually create that day, it just offsets to get to March. Same thing with the months: 2023-13-01 probably isn't accepted just because it isn't in the valid range of months (1-12). But it doesn't seem like there's a better way to do that.
Strangely, it seems like DateComponents does the same thing when given an invalid date.
let components = DateComponents(calendar: .current, year: 2023, month: 2, day: 32)
let date = components.date!
// 2023-03-04 05:00:00 +0000
That DateComponents initializer isn't failable though, and there's nothing in its documentation saying anything about validation of the values, so you can give it any values:
let components = DateComponents(calendar: .current, year: .max, month: .max, day: .max)
let date = components.date!
print(date) // 0000-12-31 23:06:32 +0000
The formatter.date(from:) on the other hand returns an optional date, and I just expected it to fail for all invalid date strings, not just for some invalid date strings.
The DateComponents behaviour is expected (at least by me :-). AFAICR it’s always worked this way. I did a quick check of the docs to see if I could find anything concrete to support that recollection but turned up blank )-:
And yeah, I suspect that ISO8601DateFormatter just parses the components and passes them to the Gregorian calendar’s date(from:) method to get a date.
As to what you can do about this, the FormatStyle API seems to do the right thing here. That is, this:
import Foundation
func test(_ input: String) {
do {
let fs = Date.ISO8601FormatStyle()
let d = try fs.parseStrategy.parse(input)
print("\(input) -> \(d)")
} catch {
print("\(input) -> NG")
}
}
test("2023-02-28T12:00:00Z")
test("2023-02-29T12:00:00Z")
test("2024-02-29T12:00:00Z")
test("2023-02-30T12:00:00Z")
prints this:
2023-02-28T12:00:00Z -> 2023-02-28 12:00:00 +0000
2023-02-29T12:00:00Z -> NG
2024-02-29T12:00:00Z -> 2024-02-29 12:00:00 +0000
2023-02-30T12:00:00Z -> NG