Incorrect DateEncodingStrategy when using Date wrapped in AnyEncodable

It seems that wrapping Date instances in a type-erased AnyEncodable wrapper causes the use of an incorrect date encoding strategy.
There is a workaround.
However, can someone tell me is this is a weird bug or an intended behaviour and if so what is the reason?

Given:

let date = Calendar.current.date(from: DateComponents(
    year: 2020, month: 7, day: 31, hour: 11
))!
date.timeIntervalSince1970 // -> 1596186000 
let encoder = JSONEncoder()
// important to not use the default deferredToDate to see the difference
encoder.dateEncodingStrategy = .secondsSince1970

Test 1 - encoding raw Date :white_check_mark:

String(data: try! encoder.encode(date), encoding: .utf8)
// correct "1596186000"

Test 2 - encoding Date as a property in Example :white_check_mark:

struct Example: Encodable {
    let date: Date
}

String(data: try! encoder.encode(Example(date: date)), encoding: .utf8)
// correct "{"date":1596186000}"

Introduce AnyEncodable - type-erased Encodable wrapper:

struct AnyEncodable: Encodable {

    private let encodable: Encodable

    init(_ encodable: Encodable) {
        self.encodable = encodable
    }

    func encode(to encoder: Encoder) throws {
        try self.encodable.encode(to: encoder)
    }
}

Test 3 - encoding Date as a property in Example wrapped in AnyEncodable :white_check_mark:

String(data: try! encoder.encode(AnyEncodable(Example(date: date))), encoding: .utf8)
// correct "{"date":1596186000}"

Test 4 - encoding raw Date wrapped in AnyEncodable :x:

String(data: try! encoder.encode(AnyEncodable(date)), encoding: .utf8)
// incorrect "617878800"

This test fails and uses Date default encodable form which is timeIntervalSinceReferenceDate.
Does it only ignores the date encoding strategy or it uses a whole new instance of encoder underneath?

Workaround:

extension Encodable {
    
    fileprivate func encode(into container: inout SingleValueEncodingContainer) throws {
        try container.encode(self)
    }
}

struct AnyEncodableWorkaround: Encodable {

    private let encodable: Encodable

    init(_ encodable: Encodable) {
        self.encodable = encodable
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try self.encodable.encode(into: &container)
    }
}

Test 5 - encoding Date as a property in Example wrapped in AnyEncodableWorkaround :white_check_mark:

String(data: try! encoder.encode(AnyEncodableWorkaround(Example(date: date))), encoding: .utf8)
// correct "{"date":1596186000}"

Test 6 - encoding raw Date wrapped in AnyEncodableWorkaround :white_check_mark:

String(data: try! encoder.encode(AnyEncodableWorkaround(date)), encoding: .utf8)
// correct "1596186000"

So adding this weird workaround makes both test 5 and 6 pass as oppose to test 4 from the corresponding pair of tests: 3 and 4.
Can someone tell me is this is a weird bug or an intended behaviour and if so what is the reason?
What does this single value container changes?

I have found a great explanation here, by itaiferber:

However, this seems like a very subtle difference when writing such a code, in that case wouldn't it be better to include AnyEncodable in the stdlib? I guess there are many others that implement AnyEncodable as value.encode(to: container) instead of value.encode(to: &container).

2 Likes