swiftdatecodableiso8601decodable

Codable: Decode a String into a custom type (ISO 8601 Date, no time components)


Consider the following type:

public struct DocumentDate: Codable {
    /// Year  component of the date, Integer with no limitation on the range.
    public let year: Int
    /// Month component of the date, Integer from 1 to 12.
    public let month: Int
    /// Day component of the date, Integer from 1 to 31.
    public let day: Int
}

What would be the easiest way to incorporate Codable and more specifically Decodable protocol support into this type to decode an ISO 8601-compliant date of type:

"2012-01-13"

Ideally, I'd like to get a String decoded in the initializer and then I'll be able to parse it as I wish. What would be the best strategy to implement this support?

I know I can use dateDecodingStrategy but that is to decode a String into Date. My type is actually not a Date, but a custom one, so that's where the challenge is.


Solution

  • My suggestion is to implement init(from:) and parse the date string with Regular Expression

    public struct DocumentDate: Decodable {
        /// Year  component of the date, Integer with no limitation on the range.
        public let year: Int
        /// Month component of the date, Integer from 1 to 12.
        public let month: Int
        /// Day component of the date, Integer from 1 to 31.
        public let day: Int
        
        public init(from decoder: any Decoder) throws {
            let container = try decoder.singleValueContainer()
            let dateString = try container.decode(String.self)
            guard let match = dateString.wholeMatch(of: /(\d{4})-(\d{2})-(\d{2})/)?.output else {
                throw DecodingError.dataCorruptedError(in: container, debugDescription: "\(dateString) is not a valid date format")
            }
            year = Int(match.1)!
            month = Int(match.2)!
            day = Int(match.3)!
        }
    }
    

    and an example how to use it

    let jsonString = """
    {"date":"2012-01-13"}
    """
    
    struct Test: Decodable {
        let date: DocumentDate
    }
    
    do {
        let result = try JSONDecoder().decode(Test.self, from: Data(jsonString.utf8))
        print(result)
    } catch {
        print(error)
    }
    

    You can even check if month and day are in the valid range for example

    let month = Int(match.2)!
    guard (1...12).contains(month) else { throw DecodingError.dataCorruptedError(in: container, debugDescription: "Month value \(month) is out of range") }
    self.month = month
    

    An alternative is to create a date with ISO8601DateFormatter and extract the desired date components

    public struct DocumentDate: Decodable {
        /// Year  component of the date, Integer with no limitation on the range.
        public let year: Int
        /// Month component of the date, Integer from 1 to 12.
        public let month: Int
        /// Day component of the date, Integer from 1 to 31.
        public let day: Int
        
        public init(from decoder: any Decoder) throws {
            let container = try decoder.singleValueContainer()
            let dateString = try container.decode(String.self)
            let formatter = ISO8601DateFormatter()
            formatter.formatOptions = .withFullDate
            guard let date = formatter.date(from: dateString) else {
                throw DecodingError.dataCorruptedError(in: container, debugDescription: "\(dateString) is not a valid date format")
            }
            let components = Calendar(identifier: .iso8601).dateComponents([.year, .month, .day], from: date)
            year = components.year!
            month = components.month!
            day = components.day!
        }
    }