swiftxcodedatecodabledateformatter

How to convert a date string with optional fractional seconds using Codable in Swift?


I am replacing my old JSON parsing code with Swift's Codable and am running into a bit of a snag. I guess it isn't as much a Codable question as it is a DateFormatter question.

Start with a struct

 struct JustADate: Codable {
    var date: Date
 }

and a json string

let json = """
  { "date": "2017-06-19T18:43:19Z" }
"""

now lets decode

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601

let data = json.data(using: .utf8)!
let justADate = try! decoder.decode(JustADate.self, from: data) //all good

But if we change the date so that it has fractional seconds, for example:

let json = """
  { "date": "2017-06-19T18:43:19.532Z" }
"""

Now it breaks. The dates sometimes come back with fractional seconds and sometimes do not. The way I used to solve it was in my mapping code I had a transform function that tried both dateFormats with and without the fractional seconds. I am not quite sure how to approach it using Codable however. Any suggestions?


Solution

  • With iOS 15 or later we can take advantage of Date ParseStrategy ISO8601FormatStyle. Create an iso8601 with fractional seconds strategy and use it to initialize a new Date using its generic initializer init<T>(_ value: T.ParseInput, strategy: T) throws where T : ParseStrategy, T.ParseOutput == Date. If it fails just try without optional seconds:

    Decoding


    extension ParseStrategy where Self == Date.ISO8601FormatStyle {
        static var iso8601withFractionalSeconds: Self { .init(includingFractionalSeconds: true) }
    }
    

    extension JSONDecoder.DateDecodingStrategy {
        static let iso8601withOptionalFractionalSeconds = custom {
            let string = try $0.singleValueContainer().decode(String.self)
            do {
                return try .init(string, strategy: .iso8601withFractionalSeconds)
            } catch {
                return try .init(string, strategy: .iso8601)
            }
        }
    }
    

    Encoding


    extension FormatStyle where Self == Date.ISO8601FormatStyle {
        static var iso8601withFractionalSeconds: Self { .init(includingFractionalSeconds: true) }
    }
    
    extension JSONEncoder.DateEncodingStrategy {
        static let iso8601withFractionalSeconds = custom {
            var container = $1.singleValueContainer()
            try container.encode($0.formatted(.iso8601withFractionalSeconds))
        }
    }
    

    // Playground testing
    struct ISODates: Codable {
        let dateWith9FS: Date
        let dateWith3FS: Date
        let dateWith2FS: Date
        let dateWithoutFS: Date
    }
    let isoDatesJSON = """
    {
    "dateWith9FS": "2017-06-19T18:43:19.532123456Z",
    "dateWith3FS": "2017-06-19T18:43:19.532Z",
    "dateWith2FS": "2017-06-19T18:43:19.53Z",
    "dateWithoutFS": "2017-06-19T18:43:19Z",
    }
    """
    let isoDatesData = Data(isoDatesJSON.utf8)
    
    let decoder = JSONDecoder()
    decoder.dateDecodingStrategy = .iso8601withOptionalFractionalSeconds
    
    do {
        let isoDates = try decoder.decode(ISODates.self, from: isoDatesData)
        print(isoDates.dateWith9FS.formatted(.iso8601withFractionalSeconds))
        print(isoDates.dateWith3FS.formatted(.iso8601withFractionalSeconds))
        print(isoDates.dateWith2FS.formatted(.iso8601withFractionalSeconds))
        print(isoDates.dateWithoutFS.formatted(.iso8601withFractionalSeconds))
    } catch {
        print(error)
    }
    

    Original Post:

    You can use two different date formatters (with and without fraction seconds) and create a custom DateDecodingStrategy. In case of failure when parsing the date returned by the API you can throw a DecodingError as suggested by @PauloMattos in comments:

    iOS 9, macOS 10.9, tvOS 9, watchOS 2, Xcode 9 or later

    The custom ISO8601 DateFormatter:

    extension Formatter {
        static let iso8601withFractionalSeconds: DateFormatter = {
            let formatter = DateFormatter()
            formatter.calendar = Calendar(identifier: .iso8601)
            formatter.locale = Locale(identifier: "en_US_POSIX")
            formatter.timeZone = TimeZone(secondsFromGMT: 0)
            formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX"
            return formatter
        }()
        static let iso8601: DateFormatter = {
            let formatter = DateFormatter()
            formatter.calendar = Calendar(identifier: .iso8601)
            formatter.locale = Locale(identifier: "en_US_POSIX")
            formatter.timeZone = TimeZone(secondsFromGMT: 0)
            formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssXXXXX"
            return formatter
        }()
    }
    

    The custom DateDecodingStrategy:

    extension JSONDecoder.DateDecodingStrategy {
        static let customISO8601 = custom {
            let container = try $0.singleValueContainer()
            let string = try container.decode(String.self)
            if let date = Formatter.iso8601withFractionalSeconds.date(from: string) ?? Formatter.iso8601.date(from: string) {
                return date
            }
            throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date: \(string)")
        }
    }
    

    The custom DateEncodingStrategy:

    extension JSONEncoder.DateEncodingStrategy {
        static let customISO8601 = custom {
            var container = $1.singleValueContainer()
            try container.encode(Formatter.iso8601withFractionalSeconds.string(from: $0))
        }
    }
    

    edit/update:

    Xcode 10 • Swift 4.2 or later • iOS 11.2.1 or later

    ISO8601DateFormatter now supports formatOptions .withFractionalSeconds:

    extension Formatter {
        static let iso8601withFractionalSeconds: ISO8601DateFormatter = {
            let formatter = ISO8601DateFormatter()
            formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
            return formatter
        }()
        static let iso8601: ISO8601DateFormatter = {
            let formatter = ISO8601DateFormatter()
            formatter.formatOptions = [.withInternetDateTime]
            return formatter
        }()
    }
    

    The customs DateDecodingStrategy and DateEncodingStrategy would be the same as shown above.