Swift 4 JSON Decodable simplest way to decode type change

23,709

Solution 1

Unfortunately, I don't believe such an option exists in the current JSONDecoder API. There only exists an option in order to convert exceptional floating-point values to and from a string representation.

Another possible solution to decoding manually is to define a Codable wrapper type for any LosslessStringConvertible that can encode to and decode from its String representation:

struct StringCodableMap<Decoded : LosslessStringConvertible> : Codable {

    var decoded: Decoded

    init(_ decoded: Decoded) {
        self.decoded = decoded
    }

    init(from decoder: Decoder) throws {

        let container = try decoder.singleValueContainer()
        let decodedString = try container.decode(String.self)

        guard let decoded = Decoded(decodedString) else {
            throw DecodingError.dataCorruptedError(
                in: container, debugDescription: """
                The string \(decodedString) is not representable as a \(Decoded.self)
                """
            )
        }

        self.decoded = decoded
    }

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

Then you can just have a property of this type and use the auto-generated Codable conformance:

struct Example : Codable {

    var name: String
    var age: Int
    var taxRate: StringCodableMap<Float>

    private enum CodingKeys: String, CodingKey {
        case name, age
        case taxRate = "tax_rate"
    }
}

Although unfortunately, now you have to talk in terms of taxRate.decoded in order to interact with the Float value.

However you could always define a simple forwarding computed property in order to alleviate this:

struct Example : Codable {

    var name: String
    var age: Int

    private var _taxRate: StringCodableMap<Float>

    var taxRate: Float {
        get { return _taxRate.decoded }
        set { _taxRate.decoded = newValue }
    }

    private enum CodingKeys: String, CodingKey {
        case name, age
        case _taxRate = "tax_rate"
    }
}

Although this still isn't as a slick as it really should be – hopefully a later version of the JSONDecoder API will include more custom decoding options, or else have the ability to express type conversions within the Codable API itself.

However one advantage of creating the wrapper type is that it can also be used in order to make manual decoding and encoding simpler. For example, with manual decoding:

struct Example : Decodable {

    var name: String
    var age: Int
    var taxRate: Float

    private enum CodingKeys: String, CodingKey {
        case name, age
        case taxRate = "tax_rate"
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        self.name = try container.decode(String.self, forKey: .name)
        self.age = try container.decode(Int.self, forKey: .age)
        self.taxRate = try container.decode(StringCodableMap<Float>.self,
                                            forKey: .taxRate).decoded
    }
}

Solution 2

Using Swift 5.1, you may choose one of the three following ways in order to solve your problem.


#1. Using Decodable init(from:) initializer

Use this strategy when you need to convert from String to Float for a single struct, enum or class.

import Foundation

struct ExampleJson: Decodable {

    var name: String
    var age: Int
    var taxRate: Float

    enum CodingKeys: String, CodingKey {
        case name, age, taxRate = "tax_rate"
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        name = try container.decode(String.self, forKey: CodingKeys.name)
        age = try container.decode(Int.self, forKey: CodingKeys.age)
        let taxRateString = try container.decode(String.self, forKey: CodingKeys.taxRate)
        guard let taxRateFloat = Float(taxRateString) else {
            let context = DecodingError.Context(codingPath: container.codingPath + [CodingKeys.taxRate], debugDescription: "Could not parse json key to a Float object")
            throw DecodingError.dataCorrupted(context)
        }
        taxRate = taxRateFloat
    }

}

Usage:

import Foundation

let jsonString = """
{
  "name": "Bob",
  "age": 25,
  "tax_rate": "4.25"
}
"""

let data = jsonString.data(using: String.Encoding.utf8)!
let decoder = JSONDecoder()
let exampleJson = try! decoder.decode(ExampleJson.self, from: data)
dump(exampleJson)
/*
 prints:
 ▿ __lldb_expr_126.ExampleJson
   - name: "Bob"
   - age: 25
   - taxRate: 4.25
 */

#2. Using an intermediate model

Use this strategy when you have many nested keys in your JSON or when you need to convert many keys (e.g. from String to Float) from your JSON.

import Foundation

fileprivate struct PrivateExampleJson: Decodable {

    var name: String
    var age: Int
    var taxRate: String

    enum CodingKeys: String, CodingKey {
        case name, age, taxRate = "tax_rate"
    }

}

struct ExampleJson: Decodable {

    var name: String
    var age: Int
    var taxRate: Float

    init(from decoder: Decoder) throws {
        let privateExampleJson = try PrivateExampleJson(from: decoder)

        name = privateExampleJson.name
        age = privateExampleJson.age
        guard let convertedTaxRate = Float(privateExampleJson.taxRate) else {
            let context = DecodingError.Context(codingPath: [], debugDescription: "Could not parse json key to a Float object")
            throw DecodingError.dataCorrupted(context)
        }
        taxRate = convertedTaxRate
    }

}

Usage:

import Foundation

let jsonString = """
{
  "name": "Bob",
  "age": 25,
  "tax_rate": "4.25"
}
"""

let data = jsonString.data(using: String.Encoding.utf8)!
let decoder = JSONDecoder()
let exampleJson = try! decoder.decode(ExampleJson.self, from: data)
dump(exampleJson)
/*
 prints:
 ▿ __lldb_expr_126.ExampleJson
   - name: "Bob"
   - age: 25
   - taxRate: 4.25
 */

#3. Using a KeyedDecodingContainer extension method

Use this strategy when converting from some JSON keys' types to your model's property types (e.g. String to Float) is a common pattern in your application.

import Foundation

extension KeyedDecodingContainer  {

    func decode(_ type: Float.Type, forKey key: Key) throws -> Float {
        if let stringValue = try? self.decode(String.self, forKey: key) {
            guard let floatValue = Float(stringValue) else {
                let context = DecodingError.Context(codingPath: codingPath, debugDescription: "Could not parse json key to a Float object")
                throw DecodingError.dataCorrupted(context)
            }
            return floatValue
        } else {
            let doubleValue = try self.decode(Double.self, forKey: key)
            return Float(doubleValue)
        }
    }

}

struct ExampleJson: Decodable {

    var name: String
    var age: Int
    var taxRate: Float

    enum CodingKeys: String, CodingKey {
        case name, age, taxRate = "tax_rate"
    }

}

Usage:

import Foundation

let jsonString = """
{
    "name": "Bob",
    "age": 25,
    "tax_rate": "4.25"
}
"""

let data = jsonString.data(using: String.Encoding.utf8)!
let decoder = JSONDecoder()
let exampleJson = try! decoder.decode(ExampleJson.self, from: data)
dump(exampleJson)
/*
 prints:
 ▿ __lldb_expr_126.ExampleJson
 - name: "Bob"
 - age: 25
 - taxRate: 4.25
 */

Solution 3

You can always decode manually. So, given:

{
    "name": "Bob",
    "age": 25,
    "tax_rate": "4.25"
}

You can do:

struct Example: Codable {
    let name: String
    let age: Int
    let taxRate: Float

    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        name = try values.decode(String.self, forKey: .name)
        age = try values.decode(Int.self, forKey: .age)
        guard let rate = try Float(values.decode(String.self, forKey: .taxRate)) else {
            throw DecodingError.dataCorrupted(.init(codingPath: [CodingKeys.taxRate], debugDescription: "Expecting string representation of Float"))
        }
        taxRate = rate
    }

    enum CodingKeys: String, CodingKey {
        case name, age
        case taxRate = "tax_rate"
    }
}

See Encode and Decode Manually in Encoding and Decoding Custom Types.

But I agree, that it seems like there should be a more elegant string conversion process equivalent to DateDecodingStrategy given how many JSON sources out there incorrectly return numeric values as strings.

Solution 4

I know that this is a really late answer, but I started working on Codable couple of days back only. And I bumped into a similar issue.

In order to convert the string to floating number, you can write an extension to KeyedDecodingContainer and call the method in the extension from init(from decoder: Decoder){}

For the problem mentioned in this issue, see the extension I wrote below;

extension KeyedDecodingContainer {

    func decodeIfPresent(_ type: Float.Type, forKey key: K, transformFrom: String.Type) throws -> Float? {

        guard let value = try decodeIfPresent(transformFrom, forKey: key) else {
            return nil
        }
        return Float(value)
    }

    func decode(_ type: Float.Type, forKey key: K, transformFrom: String.Type) throws -> Float {

        guard let valueAsString = try? decode(transformFrom, forKey: key),
            let value = Float(valueAsString) else {

            throw DecodingError.typeMismatch(
                type, 
                DecodingError.Context(
                    codingPath: codingPath, 
                    debugDescription: "Decoding of \(type) from \(transformFrom) failed"
                )
            )
        }
        return value
    }
}

You can call this method from init(from decoder: Decoder) method. See an example below;

init(from decoder: Decoder) throws {

    let container = try decoder.container(keyedBy: CodingKeys.self)

    taxRate = try container.decodeIfPresent(Float.self, forKey: .taxRate, transformFrom: String.self)
}

In fact, you can use this approach to convert any type of data to any other type. You can convert string to Date, string to bool, string to float, float to int etc.

Actually to convert a string to Date object, I will prefer this approach over JSONEncoder().dateEncodingStrategy because if you write it properly, you can include different date formats in the same response.

Hope I helped.

Updated the decode method to return non-optional on suggestion from @Neil.

Solution 5

I used Suran's version, but updated it to return non-optional value for decode(). To me this is the most elegant version. Swift 5.2.

extension KeyedDecodingContainer {

func decodeIfPresent(_ type: Float.Type, forKey key: K, transformFrom: String.Type) throws -> Float? {
    guard let value = try decodeIfPresent(transformFrom, forKey: key) else {
        return nil
    }
    return Float(value)
}

func decode(_ type: Float.Type, forKey key: K, transformFrom: String.Type) throws -> Float {
    guard let str = try? decode(transformFrom, forKey: key),
        let value = Float(str) else {
            throw DecodingError.typeMismatch(Int.self, DecodingError.Context(codingPath: codingPath, debugDescription: "Decoding of \(type) from \(transformFrom) failed"))
    }
    return value
}
}
Share:
23,709

Related videos on Youtube

Dru Freeman
Author by

Dru Freeman

Nearly 30 years professional experience developing exclusively on Apple based platforms as a third party developer. Initial computer experience is an Apple ][ (not a plus) purchased for me as a pre-teen. Work history includes Earthlink, Symantec, and Microsoft. I don't do windows. I deal with Android when I need to. Someday I will release my very own... did it for myself... app. Twitter: @PodcastDru

Updated on July 09, 2022

Comments

  • Dru Freeman
    Dru Freeman almost 2 years

    With Swift 4's Codable protocol there's a great level of under the hood date and data conversion strategies.

    Given the JSON:

    {
        "name": "Bob",
        "age": 25,
        "tax_rate": "4.25"
    }
    

    I want to coerce it into the following structure

    struct ExampleJson: Decodable {
        var name: String
        var age: Int
        var taxRate: Float
    
        enum CodingKeys: String, CodingKey {
           case name, age 
           case taxRate = "tax_rate"
        }
    }
    

    The Date Decoding Strategy can convert a String based date into a Date.

    Is there something that does that with a String based Float

    Otherwise I've been stuck with using CodingKey to bring in a String and use a computing get:

        enum CodingKeys: String, CodingKey {
           case name, age 
           case sTaxRate = "tax_rate"
        }
        var sTaxRate: String
        var taxRate: Float { return Float(sTaxRate) ?? 0.0 }
    

    This sort of strands me doing more maintenance than it seems should be needed.

    Is this the simplest manner or is there something similar to DateDecodingStrategy for other type conversions?

    Update: I should note: I've also gone the route of overriding

    init(from decoder:Decoder)
    

    But that is in the opposite direction as it forces me to do it all for myself.

    • Dru Freeman
      Dru Freeman about 7 years
      Thanks @Rob, I fixed the question with that oversight.
    • chrismanderson
      chrismanderson about 7 years
      I hit this same issue and opened a !Swift bug. Wrapping numbers as strings in JSON is so common, and I hope the Swift team handles this case.
    • chrismanderson
      chrismanderson about 7 years
      And it looks the Swift team is taking a look at this issue. Fingers crossed!
    • Imanou Petit
      Imanou Petit over 6 years
      See my answer that shows up to 3 different ways to solve your problem.
  • Dru Freeman
    Dru Freeman about 7 years
    I appreciate this response. I've edited my original query, that I had gone this route; but that is in the opposite direction of my goal. This is good info to those still learning this new API.
  • Dru Freeman
    Dru Freeman about 7 years
    So does this then become a Swift Proposal?
  • Hamish
    Hamish about 7 years
    @LordAndrei I would recommend raising it on the swift evolution mailing list. My initial feeling is that it would be better to just have it as an extra option for JSONDecoder/JSONEncoder, rather than as a big overhaul of Codable. Given the existing option for decoding and encoding exceptional floating-point values to strings, it seems like a natural place for it to go.
  • Srivathsalachary Vangeepuram
    Srivathsalachary Vangeepuram over 6 years
    The KeyedDecodingContainer option is good as long as all of your floats are represented as strings. If the JSON includes a float that doesn't have quotes, you'll get a decoding error, because KeyedDecodingContainer will be expecting a string.
  • Imanou Petit
    Imanou Petit over 6 years
    @TomHarrington Totally true. I'll update my answer later to fix this issue. Thanks.
  • ScottyBlades
    ScottyBlades over 6 years
    The first option worked for me only after taking the enum out of the struct declaration. Thank you!
  • mrfour
    mrfour over 6 years
    It's best solution for me, minimalism 🎉
  • Neil
    Neil over 4 years
    I found this to be the most elegant solution. However, the decode() version should not return an optional. I will post the non-optional version as a new answer.
  • David
    David over 4 years
    Thank you thank you. This functionality should be built into the decoder (although don't ask me why the server sometimes puts a number in quotes and sometimes not).
  • Ribena
    Ribena over 3 years
    This looks good. How would this work for encoding as well as decoding? And could I create a bunch of typealiases (HexA, HexB, HexC, etc.) tied to String to force different kinds of conversion to Int? I have a question with more details about my use case: stackoverflow.com/questions/65314663/…