James Sumners
James Sumners

Reputation: 14785

Create a custom Date class/struct that inherits from Date/NSDate?

I would like to create an extension to Date, or NSDate, with a custom initializer that parses date strings in the format yyyy-MM-dd. I need this to have a different symbol name, so I can't create an actual extension to the best of my knowledge.

I have written the following, but the self.init line is evidently invalid, and I haven't been able to figure out how to finalize initialization:

class DateOnly: NSDate, @unchecked Sendable {
    convenience init(from: String) {
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy-MM-dd"
        formatter.locale = NSLocale(localeIdentifier: "en_US_POSIX") as Locale
        let d = formatter.date(from: from)!
        
        self.init(timeInterval: 0, since: d)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

So my question is: how can I update this code to work as intended? Or what code do I need to write to accomplish my goal?


For a little more background:

I already have a couple of other extensions to support RFC 3339 date-time strings:

extension Formatter {
        static func rfc3339Formatter() -> ISO8601DateFormatter {
                let formatter = ISO8601DateFormatter()
                formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
                return formatter
        }
}

extension JSONDecoder.DateDecodingStrategy {
        static let rfc3339 = custom { decoder in
                let dateStr = try decoder.singleValueContainer().decode(String.self)
                let formatter = Formatter.rfc3339Formatter()
                if let date = formatter.date(from: dateStr) {
                        return date
                }
                throw DecodingError.dataCorrupted(
                        DecodingError.Context(
                                codingPath: decoder.codingPath,
                                debugDescription: "Invalid date"
                        )
                )
        }
}

This works as expected for payloads that I need to parse which only contain full-date strings. For example, I am able to add a method to my structs that provide a preconfigured decoder:

    static let decoder = {
            let decoder = JSONDecoder()
            decoder.dateDecodingStrategy = .rfc3339
            return decoder
    }()

But I need to parse payloads that will have both types of strings to parse. So I want to be able to write a struct like:

struct Foo: Decodable {
  let date1: Date // This is an RFC 3339 `date-time` string
  let date2: MyCustomDate // This is a simple `yyyy-MM-dd` string

  static let decoder = {
    let decoder = JSONDecoder()
    decoder.dateDecodingStrategy = .rfc3339
    return decoder
  }()
}

And then implement whatever decoding protocol or such that I need to for MyCustomDate that would transparently allow for parsing a JSON representation of Foo.

Upvotes: 0

Views: 135

Answers (3)

Alexander
Alexander

Reputation: 63369

Customizing the Codable behaviour on a per-field basis is best done with a property wrapper.

This is nearly identical to Joakim's answer, except the struct needs to be tagged @propertyWrapper, and its variable has to be called wrappedValue instead of date.

The key difference is that @DateOnly var d2: Date is still just a Date, just like var d1: Date. You can access it directly, without need to access some child field like .date.

import Foundation

// A wrapper which encodes or decodes using a custom yyyy-mm-dd format.
@propertyWrapper
struct DateOnly: Codable {
    let wrappedValue: Date
    
    private static let formatStyle: Date.FormatStyle =
        .dateTime
            .year(.defaultDigits)
            .month(.twoDigits)
            .day(.twoDigits)

    init(wrappedValue: Date) {
        self.wrappedValue = wrappedValue
    }

    init(from decoder: any Decoder) throws {
        let container = try decoder.singleValueContainer()
        let string = try container.decode(String.self)
        self.wrappedValue = try Self.formatStyle.parse(string)
    }

    func encode(to encoder: any Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(self.wrappedValue.formatted(Self.formatStyle))
    }
}

Example usage:

struct S: Codable {
    var d1: Date
    @DateOnly var d2: Date
}

let exampleStruct = S(d1: Date(), d2: Date())

let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted

let json = String(data: try encoder.encode(exampleStruct), encoding: .utf8)!
print(json)
// {
//  "d1" : 759528257.233482,
//  "d2" : "2025-01-25"
//}

Upvotes: 1

Leo Dabus
Leo Dabus

Reputation: 236498

What you need is to adjust your custom JSONDecoder.DateDecodingStrategy:

iOS 15 or later

extension ParseStrategy where Self == Date.ISO8601FormatStyle {
    static var iso8601withFractionalSeconds: Self { .init(includingFractionalSeconds: true) }
    static var iso8601withoutTime: Self { iso8601.year().month().day() }
}

extension JSONDecoder.DateDecodingStrategy {
    static let iso8601withOptionalTime = custom {
        let string = try $0.singleValueContainer().decode(String.self)
        do {
            return try .init(string, strategy: .iso8601withFractionalSeconds)
        } catch {
            return try .init(string, strategy: .iso8601withoutTime)
        }
    }
}

Playground testing

struct ISOWithOpionalTimeDates: Codable {
    let dateWithFractionalSeconds: Date
    let dateWithoutTime: Date
}
let isoDatesJSON = """
{
"dateWithFractionalSeconds": "2017-06-19T18:43:19.123Z",
"dateWithoutTime": "2017-06-19",
}
"""

let isoDatesData = Data(isoDatesJSON.utf8)

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601withOptionalTime

do {
    let isoDates = try decoder.decode(ISOWithOpionalTimeDates.self, from: isoDatesData)
    print(isoDates)
} catch {
    print(error)
}

This will print

ISOWithOpionalTimeDates(dateWithFractionalSeconds: 2017-06-19 18:43:19 +0000, dateWithoutTime: 2017-06-19 00:00:00 +0000)

If you need to support older than iOS15 you can check this post

Upvotes: 1

Joakim Danielson
Joakim Danielson

Reputation: 52043

Rather than trying to subclass a Foundation type I believe it is better to wrap it in a custom type and add custom encoding and decoding to that type.

struct DateOnly: Codable {
    let date: Date
    private static let formatStyle: Date.FormatStyle =
        .dateTime
            .year(.defaultDigits)
            .month(.twoDigits)
            .day(.twoDigits)

    init(dateString: String) throws {
        self.date = try Self.formatStyle.parse(dateString)
    }

    init(date: Date) {
        self.date = date
    }

    init(from decoder: any Decoder) throws {
        let container = try decoder.singleValueContainer()
        let string = try container.decode(String.self)
        self.date = try Self.formatStyle.parse(string)
    }

    func encode(to encoder: any Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(self.date.formatted(Self.formatStyle))
    }
}

Upvotes: 2

Related Questions