How to convert a date string with optional fractional seconds using Codable in Swift4
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 testingstruct 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 = .customISO8601do { 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)}
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 millisecondslazy 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 millisecondslazy 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 millisecondslazy 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")
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 aliaspublic typealias ISO8601FractionalDateCoding = CodingUses<FractionalSecondsISO8601DateStaticCoder>// Usage@ISO8601FractionalDateCodingstruct JustADate: Codable { var date: Date }