66

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?

Leo Dabus
  • 229,809
  • 59
  • 489
  • 571
Guillermo Alvarez
  • 1,695
  • 2
  • 18
  • 23
  • As I shared on the post above, I am not having issues with the formatting itself, rather that the API returns two different formats. Sometimes with fractional seconds and sometimes without. I have not been able to find a way to handle both possibilities. – Guillermo Alvarez Sep 27 '17 at 23:14

4 Answers4

103

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)
}
Leo Dabus
  • 229,809
  • 59
  • 489
  • 571
  • 1
    Great idea. Not sure why I didn't think about that one!!! Sometimes it just takes an extra pair of eyes to see the obvious... – Guillermo Alvarez Sep 28 '17 at 01:05
  • 1
    Yup my plan is to use the nil coalescing operator approach in the custom decoder. Should work great. Thanks again. – Guillermo Alvarez Sep 28 '17 at 01:15
  • 1
    Creating a new error type might look like a good idea initially but I would say you are better off throwing the standard `DecodingError` instead — the `case dataCorrupted` might just be what you were looking for ;) – Paulo Mattos Dec 03 '17 at 19:21
  • 1
    Good extension~~ – Brownsoo Han Apr 01 '19 at 01:59
  • 2
    @leodabus FYI: the Swift4/iOS 11 decoding does not work for the `dateWithoutFS`. On the other hand, the original extension works well. – Fmessina Sep 11 '19 at 08:58
  • @Fmessina It works for me with both of them https://www.dropbox.com/s/gb24jmnpdi37pdx/Date%20without%20FS.jpg?dl=1 – Leo Dabus Apr 08 '20 at 15:56
  • I like this approach a lot. How would you handle using the custom `DateEncodingStrategy` with multiple formatters like the `DateDecodingStrategy`? – Atticus Aug 29 '22 at 20:59
  • @Atticus Can you elaborate? `customISO8601` already deals with multiple date formats. I wonder why would you need different encoding strategies. I would suggest changing the API. If you have no control over it you would need a custom encoding for your Model. – Leo Dabus Aug 29 '22 at 21:03
5

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")

lukszar
  • 1,252
  • 10
  • 13
  • "If you want to support older systems, it makes 4 formatters." Why ? The old approach works for all of them – Leo Dabus Sep 29 '20 at 20:35
  • Btw why are you using a single `Z` in one format and `ZZZZZ` at the other? Note that `Z` does NOT uses `Z` for `UTC` timezone representation it uses `+0000` while `ZZZZZ` which is the same as `XXXXX` uses `Z`. In other words you should use `XXXXX` or `ZZZZZ` for both of them. – Leo Dabus Sep 29 '20 at 20:40
  • @LeoDabus There is parser for date with milliseconds and without. And because there is old and new way of doing that it makes 4 formatters. If you ask why to use new formatter instead the old one, on Apple docs you can find the statement ````When working with date representations in ISO 8601 format, use ISO8601DateFormatter instead.````. As ISO8601DateFormatter is working just in iOS10 and newer, this solution support old and new platform at once. – lukszar Oct 01 '20 at 09:05
  • No ISO8601DateFormatter `withFractionalSeconds` does NOT work for iOS 10 and or iOS11. It only works for iOS 11.2.1 or later before that only without fractional seconds. – Leo Dabus Oct 06 '20 at 00:14
  • @LeoDabus check please Apple documentation: https://developer.apple.com/documentation/foundation/iso8601dateformatter/options according to which fractionalSeconds works from iOS 11.0+ – lukszar Oct 09 '20 at 16:19
  • I don't need. You can check it yourself. Give it a try. The issue is with `.withFractionalSeconds`. https://developer.apple.com/documentation/foundation/iso8601dateformatter/options/2923300-withfractionalseconds It says iOS 11 but it only works with 11.2.1 – Leo Dabus Oct 09 '20 at 16:21
  • 1
    @LeoDabus you are right. There is probably mistake in Apple documentation. I checked on 11.0.1 and error appeared indeed. – lukszar Oct 09 '20 at 17:22
  • As I said it is better to use the "old" approach which works for all iOS versions – Leo Dabus Oct 09 '20 at 17:25
  • Note also that using `lazy` there is pointless. There is no need to access `self` anywhere in your code. You should change `lazy var` to `let`. – Leo Dabus Dec 21 '21 at 00:59
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
 }
GetSwifty
  • 7,568
  • 1
  • 29
  • 46
  • This is wrong. You are only specifying `.fractionalSeconds`. It should be `[.withInternetDateTime, .withFractionalSeconds]`. Another option is to simply insert `.withFractionalSeconds` to the `ISO8601DateFormatter` default options. – Leo Dabus Sep 29 '20 at 20:32
0

Alternatively to @Leo's answer, and if you need to provide support for older OS'es (ISO8601DateFormatter is available only starting with iOS 10, mac OS 10.12), you can write a custom formatter that uses both formats when parsing the string:

class MyISO8601Formatter: DateFormatter {

    static let formatters: [DateFormatter] = [
        iso8601Formatter(withFractional: true),
        iso8601Formatter(withFractional: false)
        ]

    static func iso8601Formatter(withFractional fractional: Bool) -> 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\(fractional ? ".SSS" : "")XXXXX"
        return formatter
    }

    override public func getObjectValue(_ obj: AutoreleasingUnsafeMutablePointer<AnyObject?>?,
                                 for string: String,
                                 errorDescription error: AutoreleasingUnsafeMutablePointer<NSString?>?) -> Bool {
        guard let date = (type(of: self).formatters.flatMap { $0.date(from: string) }).first else {
            error?.pointee = "Invalid ISO8601 date: \(string)" as NSString
            return false
        }
        obj?.pointee = date as NSDate
        return true
    }

    override public func string(for obj: Any?) -> String? {
        guard let date = obj as? Date else { return nil }
        return type(of: self).formatters.flatMap { $0.string(from: date) }.first
    }
}

, which you can use it as date decoding strategy:

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(MyISO8601Formatter())

Although a little bit uglier in implementation, this has the advantage of being consistent with the decoding errors that Swift throws in case of malformed data, as we don't alter the error reporting mechanism).

For example:

struct TestDate: Codable {
    let date: Date
}

// I don't advocate the forced unwrap, this is for demo purposes only
let jsonString = "{\"date\":\"2017-06-19T18:43:19Z\"}"
let jsonData = jsonString.data(using: .utf8)!
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(MyISO8601Formatter())
do {
    print(try decoder.decode(TestDate.self, from: jsonData))
} catch {
    print("Encountered error while decoding: \(error)")
}

will print TestDate(date: 2017-06-19 18:43:19 +0000)

Adding the fractional part

let jsonString = "{\"date\":\"2017-06-19T18:43:19.123Z\"}"

will result in the same output: TestDate(date: 2017-06-19 18:43:19 +0000)

However using an incorrect string:

let jsonString = "{\"date\":\"2017-06-19T18:43:19.123AAA\"}"

will print the default Swift error in case of incorrect data:

Encountered error while decoding: dataCorrupted(Swift.DecodingError.Context(codingPath: [__lldb_expr_84.TestDate.(CodingKeys in _B178608BE4B4E04ECDB8BE2F689B7F4C).date], debugDescription: "Date string does not match format expected by formatter.", underlyingError: nil))
Cristik
  • 30,989
  • 25
  • 91
  • 127
  • This won't work for both cases (with or without fractional seconds) – Leo Dabus Jan 23 '18 at 03:27
  • The only alternative is for iOS 11 or later you can use `ISO8601DateFormatter` formatOptions `.withFractionalSeconds` instead of using `DateFormatter` but the custom `dateDecodingStrategy` approach remains the same so there is no advantage using it considering the disadvantage of the iOS 11 or later restriction. – Leo Dabus Jan 23 '18 at 16:15
  • Why would you waste your time with this? kkkk The custom implementation it is much cleaner and it throws a DecodingError. Btw my original answer does work with iOS10 and doesn't require ISO8601DateFormatter – Leo Dabus Jan 25 '18 at 07:01
  • I can choose any `dateEncodingStrategy` also when encoding – Leo Dabus Jan 25 '18 at 07:14
  • No worries I just wanted to show you how to implement it both ways. Better to use the appropriate method instead of hacking around it – Leo Dabus Jan 25 '18 at 19:34
  • Note that ISO8601DateFormatter with fractional seconds it is only available in iOS 11 or later and dateDecodingStrategy requires Xcode 9 or later – Leo Dabus Jan 25 '18 at 19:47