47

Swift's JSONDecoder offers a dateDecodingStrategy property, which allows us to define how to interpret incoming date strings in accordance with a DateFormatter object.

However, I am currently working with an API that returns both date strings (yyyy-MM-dd) and datetime strings (yyyy-MM-dd HH:mm:ss), depending on the property. Is there a way to have the JSONDecoder handle this, since the provided DateFormatter object can only deal with a single dateFormat at a time?

One ham-handed solution is to rewrite the accompanying Decodable models to just accept strings as their properties and to provide public Date getter/setter variables, but that seems like a poor solution to me. Any thoughts?

RamwiseMatt
  • 2,717
  • 3
  • 16
  • 22
  • https://stackoverflow.com/questions/46458487/how-to-convert-a-date-string-with-optional-fractional-seconds-using-codable-in-s – Leo Dabus Mar 24 '19 at 15:25
  • I have written a simple extension to KeyedDecodingContainer and parsing dates in an effective manner. Please scroll down and check my answer https://stackoverflow.com/a/70304185/9290040 – Kishore Kankata Dec 10 '21 at 12:01
  • Another approach, that uses an extension to DateDecodingStrategy https://stackoverflow.com/a/74017518/364446 – KPM Oct 10 '22 at 15:54

10 Answers10

52

Please try decoder configurated similarly to this:

lazy var decoder: JSONDecoder = {
    let decoder = JSONDecoder()
    decoder.dateDecodingStrategy = .custom({ (decoder) -> Date in
        let container = try decoder.singleValueContainer()
        let dateStr = try container.decode(String.self)
        // possible date strings: "2016-05-01",  "2016-07-04T17:37:21.119229Z", "2018-05-20T15:00:00Z"
        let len = dateStr.count
        var date: Date? = nil
        if len == 10 {
            date = dateNoTimeFormatter.date(from: dateStr)
        } else if len == 20 {
            date = isoDateFormatter.date(from: dateStr)
        } else {
            date = self.serverFullDateFormatter.date(from: dateStr)
        }
        guard let date_ = date else {
            throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date string \(dateStr)")
        }
        print("DATE DECODER \(dateStr) to \(date_)")
        return date_
    })
    return decoder
}()
Leszek Zarna
  • 3,253
  • 26
  • 26
  • See here an example for how you could set up dateNoTimeFormatter: https://stackoverflow.com/questions/73593839/date-format-in-swift-ios – Oded Ben Dov Aug 05 '23 at 08:10
40

There are a few ways to deal with this:

  • You can create a DateFormatter subclass which first attempts the date-time string format, then if it fails, attempts the plain date format
  • You can give a .custom Date decoding strategy wherein you ask the Decoder for a singleValueContainer(), decode a string, and pass it through whatever formatters you want before passing the parsed date out
  • You can create a wrapper around the Date type which provides a custom init(from:) and encode(to:) which does this (but this isn't really any better than a .custom strategy)
  • You can use plain strings, as you suggest
  • You can provide a custom init(from:) on all types which use these dates and attempt different things in there

All in all, the first two methods are likely going to be the easiest and cleanest — you'll keep the default synthesized implementation of Codable everywhere without sacrificing type safety.

Itai Ferber
  • 28,308
  • 5
  • 77
  • 83
  • The first approach is the one I was looking for. Thanks! – RamwiseMatt Jun 22 '17 at 10:47
  • 3
    With `Codable` it seems strange that all the other json mapping information is provided directly from the according objects (e.g. the mapping to json keys via `CodingKeys`), but the date formatting is configured via `JSONDecoder` for the whole DTO tree. Having used Mantle in the past, the last one of your proposed solutions feels like the most appropriate one, even though it means to repeat a lot of mapping code for the other fields that could be autogenerated otherwise. – fabb Sep 01 '17 at 05:59
  • I used the second approach `.dateDecodingStrategy = .custom { decoder in var container = try decoder.singleValueContainer(); let text = try container.decode(String.self); guard let date = serverDateFormatter1.date(from: text) ?? serverDateFormatter2.date(from: text) else { throw BadDate(text) }; return date }` – Daniel T. Apr 15 '20 at 14:09
25

Swift 5

Actually based on @BrownsooHan version using a JSONDecoder extension

JSONDecoder+dateDecodingStrategyFormatters.swift

extension JSONDecoder {

    /// Assign multiple DateFormatter to dateDecodingStrategy
    ///
    /// Usage :
    ///
    ///      decoder.dateDecodingStrategyFormatters = [ DateFormatter.standard, DateFormatter.yearMonthDay ]
    ///
    /// The decoder will now be able to decode two DateFormat, the 'standard' one and the 'yearMonthDay'
    ///
    /// Throws a 'DecodingError.dataCorruptedError' if an unsupported date format is found while parsing the document
    var dateDecodingStrategyFormatters: [DateFormatter]? {
        @available(*, unavailable, message: "This variable is meant to be set only")
        get { return nil }
        set {
            guard let formatters = newValue else { return }
            self.dateDecodingStrategy = .custom { decoder in

                let container = try decoder.singleValueContainer()
                let dateString = try container.decode(String.self)

                for formatter in formatters {
                    if let date = formatter.date(from: dateString) {
                        return date
                    }
                }

                throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date string \(dateString)")
            }
        }
    }
}

It is a bit of a hacky way to add a variable that can only be set, but you can easily transform var dateDecodingStrategyFormatters by func setDateDecodingStrategyFormatters(_ formatters: [DateFormatter]? )

Usage

lets say that you have already defined several DateFormatters in your code like so :

extension DateFormatter {
    static let standardT: DateFormatter = {
        var dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
        return dateFormatter
    }()

    static let standard: DateFormatter = {
        var dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
        return dateFormatter
    }()

    static let yearMonthDay: DateFormatter = {
        var dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "yyyy-MM-dd"
        return dateFormatter
    }()
}

you can now just assign these to the decoder straight away by setting dateDecodingStrategyFormatters :

// Data structure
struct Dates: Codable {
    var date1: Date
    var date2: Date
    var date3: Date
}

// The Json to decode 
let jsonData = """
{
    "date1": "2019-05-30 15:18:00",
    "date2": "2019-05-30T05:18:00",
    "date3": "2019-04-17"
}
""".data(using: .utf8)!

// Assigning mutliple DateFormatters
let decoder = JSONDecoder()
decoder.dateDecodingStrategyFormatters = [ DateFormatter.standardT,
                                           DateFormatter.standard,
                                           DateFormatter.yearMonthDay ]


do {
    let dates = try decoder.decode(Dates.self, from: jsonData)
    print(dates)
} catch let err as DecodingError {
    print(err.localizedDescription)
}

Sidenotes

Once again I am aware that setting the dateDecodingStrategyFormatters as a var is a bit hacky, and I dont recommend it, you should define a function instead. However it is a personal preference to do so.

Olympiloutre
  • 2,268
  • 3
  • 28
  • 38
23

try this. (swift 4)

let formatter = DateFormatter()

var decoder: JSONDecoder {
    let decoder = JSONDecoder()
    decoder.dateDecodingStrategy = .custom { decoder in
        let container = try decoder.singleValueContainer()
        let dateString = try container.decode(String.self)

        formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
        if let date = formatter.date(from: dateString) {
            return date
        }
        formatter.dateFormat = "yyyy-MM-dd"
        if let date = formatter.date(from: dateString) {
            return date
        }
        throw DecodingError.dataCorruptedError(in: container,
            debugDescription: "Cannot decode date string \(dateString)")
    }
    return decoder
}
Brownsoo Han
  • 4,549
  • 3
  • 20
  • 20
  • 1
    this will create a new date formatter and a new decoder every time you can this property – Leo Dabus Mar 24 '19 at 15:34
  • https://stackoverflow.com/questions/46458487/how-to-convert-a-date-string-with-optional-fractional-seconds-using-codable-in-s – Leo Dabus Mar 26 '19 at 17:07
16

Facing this same issue, I wrote the following extension:

extension JSONDecoder.DateDecodingStrategy {
    static func custom(_ formatterForKey: @escaping (CodingKey) throws -> DateFormatter?) -> JSONDecoder.DateDecodingStrategy {
        return .custom({ (decoder) -> Date in
            guard let codingKey = decoder.codingPath.last else {
                throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "No Coding Path Found"))
            }

            guard let container = try? decoder.singleValueContainer(),
                let text = try? container.decode(String.self) else {
                    throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Could not decode date text"))
            }

            guard let dateFormatter = try formatterForKey(codingKey) else {
                throw DecodingError.dataCorruptedError(in: container, debugDescription: "No date formatter for date text")
            }

            if let date = dateFormatter.date(from: text) {
                return date
            } else {
                throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date string \(text)")
            }
        })
    }
}

This extension allows you to create a DateDecodingStrategy for the JSONDecoder that handles multiple different date formats within the same JSON String. The extension contains a function that requires the implementation of a closure that gives you a CodingKey, and it is up to you to provide the correct DateFormatter for the provided key.

Lets say that you have the following JSON:

{
    "publication_date": "2017-11-02",
    "opening_date": "2017-11-03",
    "date_updated": "2017-11-08 17:45:14"
}

The following Struct:

struct ResponseDate: Codable {
    var publicationDate: Date
    var openingDate: Date?
    var dateUpdated: Date

    enum CodingKeys: String, CodingKey {
        case publicationDate = "publication_date"
        case openingDate = "opening_date"
        case dateUpdated = "date_updated"
    }
}

Then to decode the JSON, you would use the following code:

let dateFormatterWithTime: DateFormatter = {
    let formatter = DateFormatter()

    formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"

    return formatter
}()

let dateFormatterWithoutTime: DateFormatter = {
    let formatter = DateFormatter()

    formatter.dateFormat = "yyyy-MM-dd"

    return formatter
}()

let decoder = JSONDecoder()

decoder.dateDecodingStrategy = .custom({ (key) -> DateFormatter? in
    switch key {
    case ResponseDate.CodingKeys.publicationDate, ResponseDate.CodingKeys.openingDate:
        return dateFormatterWithoutTime
    default:
        return dateFormatterWithTime
    }
})

let results = try? decoder.decode(ResponseDate.self, from: data)
S.Moore
  • 1,277
  • 17
  • 17
4

It is a little verbose, but more flexible approach: wrap date with another Date class, and implement custom serialize methods for it. For example:

let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"

class MyCustomDate: Codable {
    var date: Date

    required init?(_ date: Date?) {
        if let date = date {
            self.date = date
        } else {
            return nil
        }
    }

    public func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        let string = dateFormatter.string(from: date)
        try container.encode(string)
    }

    required public init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let raw = try container.decode(String.self)
        if let date = dateFormatter.date(from: raw) {
            self.date = date
        } else {
            throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot parse date")
        }
    }
}

So now you are independent of .dateDecodingStrategy and .dateEncodingStrategy and your MyCustomDate dates will parsed with specified format. Use it in class:

class User: Codable {
    var dob: MyCustomDate
}

Instantiate with

user.dob = MyCustomDate(date)
Pavel Shorokhov
  • 4,485
  • 1
  • 35
  • 44
2

There is no way to do this with a single encoder. Your best bet here is to customize the encode(to encoder:) and init(from decoder:) methods and provide your own translation for one these values, leaving the built-in date strategy for the other one.

It might be worthwhile looking into passing one or more formatters into the userInfo object for this purpose.

Ben Scheirman
  • 40,531
  • 21
  • 102
  • 137
2

I have defined this extension on DateDecodingStrategy, that takes an array of date formatters as an argument:

extension JSONDecoder.DateDecodingStrategy {
  static func anyFormatter(in formatters: [DateFormatter]) -> Self {
    return .custom { decoder in
      guard formatters.count > 0 else {
        throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "No date formatter provided"))
      }
      
      guard let dateString = try? decoder.singleValueContainer().decode(String.self) else {
        throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Could not decode date string"))
      }
      
      let successfullyFormattedDates = formatters.lazy.compactMap { $0.date(from: dateString) }
      guard let date = successfullyFormattedDates.first else {
        throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Date string \"\(dateString)\" does not match any of the expected formats (\(formatters.compactMap(\.dateFormat).joined(separator: " or ")))"))
      }
      
      return date
    }
  }
}

and I use it this way:

  let format1 = DateFormatter(format: "yyyy-MM-dd'T'HH:mm:ss.SSSZ")
  let format2 = DateFormatter(format: "yyyy-MM-dd'T'HH:mmzzzzzz")
  let decoder = JSONDecoder()
  decoder.dateDecodingStrategy = .anyFormatter(in: [. format1, . format2])

It successively tries to convert the date using the different formatters provided (lazily, so that it stops after the first successful attempt), and if every format fails then it throws an error.

KPM
  • 10,558
  • 3
  • 45
  • 66
0

If you have multiple dates with different formats in single model, its bit difficult to apply .dateDecodingStrategy for each dates.

Check here https://gist.github.com/romanroibu/089ec641757604bf78a390654c437cb0 for a handy solution

Johnykutty
  • 12,091
  • 13
  • 59
  • 100
0

Add an extension to KeyedDecodingContainer

extension KeyedDecodingContainer {
func decodeDate(forKey key: KeyedDecodingContainer<K>.Key, withPossible formats: [DateFormatter]) throws -> Date? {
    
    for format in formats {
        if let date = format.date(from: try self.decode(String.self, forKey: key)) {
            return date
        }
    }
    throw DecodingError.dataCorruptedError(forKey: key, in: self, debugDescription: "Date string does not match format expected by formatter.")
}

}

and use 'try container.decodeDate(forKey: 'key', withPossible: [.iso8601Full, .yyyyMMdd])'

full solution is here:

    import Foundation

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

public struct RSSFeed: Codable {
        public let releaseDate: Date?
        public let releaseDateAndTime: Date?
}

extension RSSFeed {
    public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        releaseDate = try container.decodeDate(forKey: .releaseDate, withPossible: [.iso8601Full, .yyyyMMdd])
        releaseDateAndTime = try container.decodeDate(forKey: .releaseDateAndTime, withPossible: [.iso8601Full, .yyyyMMdd])
    }
}

extension KeyedDecodingContainer {
    func decodeDate(forKey key: KeyedDecodingContainer<K>.Key, withPossible formats: [DateFormatter]) throws -> Date? {
        
        for format in formats {
            if let date = format.date(from: try self.decode(String.self, forKey: key)) {
                return date
            }
        }
        throw DecodingError.dataCorruptedError(forKey: key, in: self, debugDescription: "Date string does not match format expected by formatter.")
    }
}

let json = """
{
"releaseDate":"2017-11-12",
"releaseDateAndTime":"2017-11-16 02:02:55"
}
"""

let data = Data(json.utf8)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full)
let rssFeed = try! decoder.decode(RSSFeed.self, from: data)

let feed = rssFeed
print(feed.releaseDate, feed.releaseDateAndTime)