Swift JSONEncoder number rounding

Solution 1:

You can extend KeyedEncodingContainer and KeyedDecodingContainer and implement a custom encoding and decoding methods to send Decimal as plain data. You would just need to set the encoder/decoder dataEncodingStrategy to deferredToData. Another possibility is to encode and decode its base64Data or encode/decode it as plain string.

extension Numeric {
    var data: Data {
        var bytes = self
        return .init(bytes: &bytes, count: MemoryLayout<Self>.size)
    }
}

extension DataProtocol {
    func decode<T: Numeric>(_ codingPath: [CodingKey], key: CodingKey) throws -> T {
        var value: T = .zero
        guard withUnsafeMutableBytes(of: &value, copyBytes) == MemoryLayout.size(ofValue: value) else {
            throw DecodingError.dataCorrupted(.init(codingPath: codingPath, debugDescription: "The key \(key) could not be converted to a numeric value: \(Array(self))"))
        }
        return value
    }
}

extension KeyedEncodingContainer {
    mutating func encode(_ value: Decimal, forKey key: K) throws {
        try encode(value.data, forKey: key)
    }
    mutating func encodeIfPresent(_ value: Decimal?, forKey key: K) throws {
        guard let value = value else { return }
        try encode(value, forKey: key)
    }
}

extension KeyedDecodingContainer {
    func decode(_ type: Decimal.Type, forKey key: K) throws -> Decimal {
        try decode(Data.self, forKey: key).decode(codingPath, key: key)
    }
    func decodeIfPresent(_ type: Decimal.Type, forKey key: K) throws -> Decimal? {
        try decodeIfPresent(Data.self, forKey: key)?.decode(codingPath, key: key)
    }
}

Playground testing:

struct Root: Codable {
    let decimal: Decimal
}

// using the string initializer for decimal is required to maintain precision
let root = Root(decimal: Decimal(string: "0.007")!)

do {
    let encoder = JSONEncoder()
    encoder.dataEncodingStrategy = .deferredToData
    let rootData = try encoder.encode(root)
    let decoder = JSONDecoder()
    decoder.dataDecodingStrategy = .deferredToData
    let root = try decoder.decode(Root.self, from: rootData)
    print(root.decimal) // prints "0.007\n" instead of "0.007000000000000001024\n" without the custom encoding and decoding methods
} catch {
    print(error)
}

To keep the data size as low as possible You can encode and decode Decimal as string:

extension String {
    func decimal(_ codingPath: [CodingKey], key: CodingKey) throws -> Decimal {
        guard let decimal = Decimal(string: self) else {
            throw DecodingError.dataCorrupted(.init(codingPath: codingPath, debugDescription: "The key \(key) could not be converted to decimal: \(self)"))
        }
        return decimal
    }

}

extension KeyedEncodingContainer {
    mutating func encode(_ value: Decimal, forKey key: K) throws {
        try encode(String(describing: value), forKey: key)
    }
    mutating func encodeIfPresent(_ value: Decimal?, forKey key: K) throws {
        guard let value = value else { return }
        try encode(value, forKey: key)
    }
}

extension KeyedDecodingContainer {
    func decode(_ type: Decimal.Type, forKey key: K) throws -> Decimal {
        try decode(String.self, forKey: key).decimal(codingPath, key: key)
    }
    func decodeIfPresent(_ type: Decimal.Type, forKey key: K) throws -> Decimal? {
        try decodeIfPresent(String.self, forKey: key)?.decimal(codingPath, key: key)
    }
}

Playground testing:

struct StringDecimal: Codable {
    let decimal: Decimal
}

let root = StringDecimal(decimal: Decimal(string: "0.007")!)
do {
    let stringDecimalData = try JSONEncoder().encode(root)
    print(String(data: stringDecimalData, encoding: .utf8)!)
    let stringDecimal = try JSONDecoder().decode(StringDecimal.self, from: stringDecimalData)
    print(stringDecimal.decimal) // "0.007\n"
} catch {
    print(error)
}

This will print

{"decimal":"0.007"}
0.007