I'm using property wrapper to serialzie Decimal
into String
. My code works fine for types like Decimal
and Dictionary
containg Decimal, but fails with Decimal?
. I have read related discussion here and here. I have a workaround, but I suspect the decoding failure is a bug in codable sythesizer and wonder if there is a way to avoid the issue in the first place.
Below is the code to demonstrate the issue (it's a bit long, but should be simple to follow).
First, this is my property wrapper. I introduced a protocol because I'd like to support not only Decimal
but also Dictionary
or Enum
containing Decimal
.
protocol PreciseDecimalWrappedValue: Codable {
associatedtype StorageType: Codable
func toStorageValue() -> StorageType
// This throws when we get a value of StorageType but fail to convert it.
static func fromStorageValue(_ storageValue: StorageType) throws -> Self
}
@propertyWrapper
struct PreciseDecimal<T: PreciseDecimalWrappedValue>: Codable {
var wrappedValue: T
init(wrappedValue: T) {
self.wrappedValue = wrappedValue
}
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let storageValue = try container.decode(T.StorageType.self)
self.wrappedValue = try T.fromStorageValue(storageValue)
}
func encode(to encoder: Encoder) throws {
try wrappedValue.toStorageValue().encode(to: encoder)
}
}
Below is an example usage:
struct SomeError: Error {
}
extension Dictionary: PreciseDecimalWrappedValue where Key: Codable, Value == Decimal {
typealias StorageType = [Key: String]
func toStorageValue() -> StorageType {
mapValues { decimal in
decimal.description
}
}
static func fromStorageValue(_ storageValue: StorageType) throws -> Self {
try storageValue.mapValues { stringValue in
guard let decimal = Decimal(string: stringValue) else {
throw SomeError()
}
return decimal
}
}
}
// Test code
func encodeAndDecode<T: Codable>(_ t: T) throws -> T {
let data = try! JSONEncoder().encode(t)
let value = try JSONDecoder().decode(T.self, from: data)
return value
}
struct Test1: Codable {
@PreciseDecimal var x: [String: Decimal] = ["a": 1.1, "b": 2.2, "c": 3.3]
}
print(try encodeAndDecode(Test1()).x)
I use SingleValueContainer
in the property wrapper. According to the doc, it should only be used for primitive type. I'm not sure if Dictionary
or Enum
is considered as primitive type or not (does anyone know it?), but my experiments show the code works fine with them. That said, I suspect it's the use of SingleValueContainer
that causes the issue with Decimal?
. Please read on.
To support using the property wrapper with Decimal?
, I added the following code:
extension Decimal: PreciseDecimalWrappedValue {
typealias StorageType = String
func toStorageValue() -> StorageType {
self.description
}
static func fromStorageValue(_ storageValue: StorageType) throws -> Self {
guard let decimal = Decimal(string: storageValue) else {
throw SomeError()
}
return decimal
}
}
extension Optional: PreciseDecimalWrappedValue where Wrapped: PreciseDecimalWrappedValue {
typealias StorageType = Optional<Wrapped.StorageType>
func toStorageValue() -> StorageType {
switch self {
case .none:
return .none
case .some(let wrapped):
return .some(wrapped.toStorageValue())
}
}
static func fromStorageValue(_ storageValue: StorageType) throws -> Self {
switch storageValue {
case .none:
return .none
case .some(let wrapped):
return .some(try Wrapped.fromStorageValue(wrapped))
}
}
}
But unfortunately it fails to decode nil value.
struct Test2: Codable {
@PreciseDecimal var x: Decimal?
}
// this works fine
print(try encodeAndDecode(Test2(x: 10)).x)
// this crashed with "Expected Optional<String> but found null value instead." error
print(try encodeAndDecode(Test2(x: nil)).x)
There are two parts involved with regard to the failure. The first part is the encoding part, which encodes Test2(x: nil)
to {"x":null}
. The second part is the decoding part, which generates the error.
I think I understand the encoding part. Codable synthesizer discards a nil
property value by default when doing encoding. But according to explanation in this thread, using property wrapper has an unexpected side effect: from the codable synthesizer's perspective, it changes an optional property to a non-optional property. That, plus the fact that I use SingleValueContainer
in the property wrapper, is the reason I got {"x":null}
, instead of {}
.
I don't understand the failure in the decoding part. The error message suggested that the codable synthesizer can't decode null
to Optional<String>
. This is something I'm not sure, because from my understanding SingleValueContainer
should be able to decode null
to Optional<String>
. Actually the null
value in JSON data is the SingleValueContainer
encoding output of nil value of Optional<String>
type. How come it only works one way and not the other way around? Could this be a bug?
Now let's suppose the decoding failure isn't a bug, the question is if there is any way to generate {}
, instead of {"x":null}
, when using property wrapper? EDIT: on a second thought, even if I could find something way to generate {}
, it wouldn't work with SingleValueContainer
because the decoding would still fail - SingleValueContainer
expect some input, instead of nothing, when doing decoding. So the issue is unsolvable with SingleValueContainer
. On the other hand, it's not obvious how I can use KeyedDecodingContainer
in my property wrapper.
I'm currently using this work around (I find it in the links I mentioned above). But I wonder if it's possible to avoid the issue in the first place?
extension KeyedDecodingContainer {
func decode<T: ExpressibleByNilLiteral>(_ type: PreciseDecimal<T>.Type, forKey key: K) throws -> PreciseDecimal<T> {
if let value = try self.decodeIfPresent(type, forKey: key) {
return value
}
return PreciseDecimal(wrappedValue: nil)
}
}