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

Solution 1:

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.


// 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 = .customISO8601

do {
    let isoDates = try decoder.decode(ISODates.self, from: isoDatesData)
    print(Formatter.iso8601withFractionalSeconds.string(from: isoDates.dateWith9FS))   // 2017-06-19T18:43:19.532Z
    print(Formatter.iso8601withFractionalSeconds.string(from: isoDates.dateWith3FS))   // 2017-06-19T18:43:19.532Z
    print(Formatter.iso8601withFractionalSeconds.string(from: isoDates.dateWith2FS))   // 2017-06-19T18:43:19.530Z
    print(Formatter.iso8601withFractionalSeconds.string(from: isoDates.dateWithoutFS)) // 2017-06-19T18:43:19.000Z
} catch {
    print(error)
}

Solution 2:

Swift 5

To parse ISO8601 string to date you have to use DateFormatter. In newer systems (f.ex. iOS11+) you can use ISO8601DateFormatter.

As long as you don't know if date contains milliseconds, you should create 2 formatters for each case. Then, during parsing String to Date use both consequently.

DateFormatter for older systems

/// Formatter for ISO8601 with milliseconds
lazy var iso8601FormatterWithMilliseconds: DateFormatter = {
    let dateFormatter = DateFormatter()
    dateFormatter.locale = Locale(identifier: "en_US_POSIX")
    dateFormatter.timeZone = TimeZone(abbreviation: "GMT")
    dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"

    return dateFormatter
}()

/// Formatter for ISO8601 without milliseconds
lazy var iso8601Formatter: DateFormatter = {
    let dateFormatter = DateFormatter()
    dateFormatter.locale = Locale(identifier: "en_US_POSIX")
    dateFormatter.timeZone = TimeZone(abbreviation: "GMT")
    dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"

    return dateFormatter
}()

ISO8601DateFormatter for newer systems (f.ex. iOS 11+)

lazy var iso8601FormatterWithMilliseconds: ISO8601DateFormatter = {
    let formatter = ISO8601DateFormatter()

    // GMT or UTC -> UTC is standard, GMT is TimeZone
    formatter.timeZone = TimeZone(abbreviation: "GMT")
    formatter.formatOptions = [.withInternetDateTime,
                               .withDashSeparatorInDate,
                               .withColonSeparatorInTime,
                               .withTimeZone,
                               .withFractionalSeconds]

    return formatter
}()

/// Formatter for ISO8601 without milliseconds
lazy var iso8601Formatter: ISO8601DateFormatter = {
    let formatter = ISO8601DateFormatter()

    // GMT or UTC -> UTC is standard, GMT is TimeZone
    formatter.timeZone = TimeZone(abbreviation: "GMT")
    formatter.formatOptions = [.withInternetDateTime,
                               .withDashSeparatorInDate,
                               .withColonSeparatorInTime,
                               .withTimeZone]

    return formatter
}()

Summary

As you can notice there is 2 formatters to create. If you want to support older systems, it makes 4 formatters. To make it more simple, check out Tomorrow on GitHub where you can see entire solution for this problem.

To convert String to Date you use:

let date = Date.fromISO("2020-11-01T21:10:56.22+02:00")

Solution 3:

A new option (as of Swift 5.1) is a Property Wrapper. The CodableWrappers library has an easy way to deal with this.

For default ISO8601

@ISO8601DateCoding 
struct JustADate: Codable {
    var date: Date
 }

If you want a custom version:

// Custom coder
@available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *)
public struct FractionalSecondsISO8601DateStaticCoder: StaticCoder {

    private static let iso8601Formatter: ISO8601DateFormatter = {
        let formatter = ISO8601DateFormatter()
        formatter.formatOptions = .withFractionalSeconds
        return formatter
    }()

    public static func decode(from decoder: Decoder) throws -> Date {
        let stringValue = try String(from: decoder)
        guard let date = iso8601Formatter.date(from: stringValue) else {
            throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Expected date string to be ISO8601-formatted."))
        }
        return date
    }

    public static func encode(value: Date, to encoder: Encoder) throws {
        try iso8601Formatter.string(from: value).encode(to: encoder)
    }
}
// Property Wrapper alias
public typealias ISO8601FractionalDateCoding = CodingUses<FractionalSecondsISO8601DateStaticCoder>

// Usage
@ISO8601FractionalDateCoding
struct JustADate: Codable {
    var date: Date
 }