JSONEncoder's dateEncodingStrategy not working
Asked Answered
F

2

5

I am trying to serialize a struct to a String using Swift 4's Encodable+JSONEncoder. The object can hold heterogenous values like String, Array, Date, Int etc.

The used approach works fine with the exception of Date. JSONEncoder's dateEncodingStrategy property is not having any effect.

Here is a snippet which reproduces the behaviour in Playground:

struct EncodableValue:Encodable {
    var value: Encodable

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

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

struct Bar: Encodable, CustomStringConvertible {
    let key: String?
    let value: EncodableValue?

    var description: String {
        let encoder = JSONEncoder()
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "E, d MMM yyyy"
        dateFormatter.locale = Locale(identifier: "en_US_POSIX")
        encoder.dateEncodingStrategy = .formatted(dateFormatter)
        let jsonData = try? encoder.encode(self)
        return String(data: jsonData!, encoding: .utf8)!
    }
}

let bar1 = Bar(key: "bar1", value: EncodableValue("12345"))
let bar2 = Bar(key: "bar2", value: EncodableValue(12345))
let bar3 = Bar(key: "bar3", value: EncodableValue(Date()))

print(String(describing: bar1))
print(String(describing: bar2))
print(String(describing: bar3))

Output:

"{"key":"bar1","value":"12345"}\n"
"{"key":"bar2","value":12345}\n"
"{"key":"bar3","value":539682026.06086397}\n"

For bar3 object: I'm expecting something like "{"key":"bar3","value":"Thurs, 3 Jan 1991"}\n", but it returns the date in the default .deferToDate strategy format.

##EDIT 1##

So I ran the same code in XCode 9 and it gives the expected output, i.e. correctly formats the date to string. I'm thinking 9.2 has a minor upgrade to Swift 4 which is breaking this feature. Not sure what to do next.

##EDIT 2##

As a temp remedy I'd used the following snippet before changing to @Hamish's approach using a closure.

struct EncodableValue:Encodable {
    var value: Encodable

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

    func encode(to encoder: Encoder) throws {
        if let date = value as? Date {
            var container = encoder.singleValueContainer()
            try container.encode(date)
        }
        else {
            try value.encode(to: encoder)
        }

    }
}
Felten answered 7/2, 2018 at 7:55 Comment(7)
I have checked your code in Swift 4 Playground and it is giving me this result for bar3 : {"key":"bar3","value":"Wed, 7 Feb 2018"}Cartesian
@ArunKumar - That's curious, because I see precisely the behavior justintime describes when using that EncodableValue wrapper.Raleigh
@ArunKumar Did you add any additional code or changes to rectify the issue? As for me too, am getting the same response.Footslog
@ShaneD nothing... I just copied your code and pasted in playgroundCartesian
@ArunKumar What's your XCode version? I was using XCode 9.2, I just ran it XCode 9 and it's working there.Felten
@ShaneD. I'm using 9.0. Let me update it first then.Cartesian
@ArunKumar Just to verify have checked it in Xcode 9 and it seems to be working. The issue is with Xcode 9.2Footslog
O
12

When using a custom date encoding strategy, the encoder intercepts calls to encode a Date in a given container and then applies the custom strategy.

However with your EncodableValue wrapper, you're not giving the encoder the chance to do this because you're calling directly into the underlying value's encode(to:) method. With Date, this will encode the value using its default representation, which is as its timeIntervalSinceReferenceDate.

To fix this, you need to encode the underlying value in a single value container to trigger any custom encoding strategies. The only obstacle to doing this is the fact that protocols don't conform to themselves, so you cannot call a container's encode(_:) method with an Encodable argument (as the parameter takes a <Value : Encodable>).

One solution to this problem is to define an Encodable extension for encoding into a single value container, which you can then use in your wrapper:

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

struct AnyEncodable : Encodable {

  var value: Encodable

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

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

This takes advantage of the fact that protocol extension members have an implicit <Self : P> placeholder where P is the protocol being extended, and the implicit self argument is typed as this placeholder (long story short; it allows us to call the encode(_:) method with an Encodable conforming type).

Another option is to have have a generic initialiser on your wrapper that type erases by storing a closure that does the encoding:

struct AnyEncodable : Encodable {

  private let _encodeTo: (Encoder) throws -> Void

  init<Value : Encodable>(_ value: Value) {
    self._encodeTo = { encoder in
      var container = encoder.singleValueContainer()
      try container.encode(value)
    }
  }

  func encode(to encoder: Encoder) throws {
    try _encodeTo(encoder)
  }
}

In both cases, you can now use this wrapper to encode heterogenous encodables while respecting custom encoding strategies:

import Foundation

struct Bar : Encodable, CustomStringConvertible {

  let key: String
  let value: AnyEncodable

  var description: String {

    let encoder = JSONEncoder()
    let dateFormatter = DateFormatter()
    dateFormatter.dateFormat = "E, d MMM yyyy"
    dateFormatter.locale = Locale(identifier: "en_US_POSIX")
    encoder.dateEncodingStrategy = .formatted(dateFormatter)

    guard let jsonData = try? encoder.encode(self) else {
      return "Bar(key: \(key as Any), value: \(value as Any))"
    }
    return String(decoding: jsonData, as: UTF8.self)
  }
}

print(Bar(key: "bar1", value: AnyEncodable("12345")))
// {"key":"bar1","value":"12345"}

print(Bar(key: "bar2", value: AnyEncodable(12345)))
// {"key":"bar2","value":12345}

print(Bar(key: "bar3", value: AnyEncodable(Date())))
// {"key":"bar3","value":"Wed, 7 Feb 2018"}
Omeara answered 7/2, 2018 at 12:51 Comment(4)
I'm guessing this works since we're using generics to defer the encoding using the value supplied at run time. Because I'd tried func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() try container.encode(value) } Which gave the error 'Cannot invoke 'encode' with an argument list of type '(Encodable)'. Neat, Thanks!Felten
@Felten Actually just thought of a possibly even neater way to do it using an extension :) And yes, it won't work if you try to call an encode(_:) method with an Encodable argument because those methods are of the form <Value : Encodable>(_ value: Value), and Encodable cannot currently satisfy such a generic placeholder because protocols don't conform to themselves.Omeara
Why do prefer the extension approach?Felten
@Felten It's just more flexible; with a constrained generic initialiser you need to know the concrete type at the call-site, but with the wrapper using the extension, you could say things like let encodables = [1, "hello", Date()].map(AnyEncodable.init) :)Omeara
R
1

You can eliminate the EncodableValue wrapper, and use a generic instead:

struct Bar<T: Encodable>: Encodable {
    let key: String
    let value: T?

    var json: String {
        let encoder = JSONEncoder()
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "E, d MMM yyyy"
        dateFormatter.locale = Locale(identifier: "en_US_POSIX")
        encoder.dateEncodingStrategy = .formatted(dateFormatter)
        let data = try! encoder.encode(self)
        return String(data: data, encoding: .utf8)!
    }
}

let bar = Bar(key: "date", value: Date())

print(bar.json)

That yields:

{"key":"date","value":"Wed, 7 Feb 2018"}
Raleigh answered 7/2, 2018 at 8:20 Comment(2)
Actually in the bigger picture, I'm using Bar as a property in another struct. struct Foo { var bars: [Bars] }, which is the actual struct I want to serialize. If I use the generic approach I won't be able to specify the concrete type. And I'll end up at my old problem you suggested this approach for here . Hope I'm making sense.Felten
In that case, assuming you can't employ option 2 outlined in that other answer (use strongly typed struct rather than heterogenous dictionary or array collections), then you can use Hamish's approach.Raleigh

© 2022 - 2025 — McMap. All rights reserved.