Why does ISO8601DateFormatter accept some invalid date strings?

For example:

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

Or is there a better way?

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
1 Like

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 documentation says:

Creates and returns a date object from the specified ISO 8601 formatted string representation.

Parameters
string
The ISO 8601 formatted string representation of a date.

Return Value
A date object, or nil if no valid date was found.

I guess the documentation is a bit open for interpretation, but I still find its current behavior surprising. Ie, that:

ISO8601DateFormatter().date(from: "2023-02-31T12:00:00Z")

results in a Date value for

2023-03-03 12:00:00 +0000

rather than nil.

Yeah, but the date property on DateComponents is optional, so theoretically it should return nil for an invalid date.

Ah, right! So now we have two similar questions.

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

Share and Enjoy

Quinn “The Eskimo!” @ DTS @ Apple

3 Likes

Thanks for the workaround.

I found it strange that the two API's with "ISO8601" in their names behave differently irt Feb 29 / Feb 30 dates.

1 Like