Streamlining JSON Encoding and Decoding in Swift with Property Wrappers

Photo by Alexandru Acea / Unsplash

As iOS developers, we frequently interact with various public and private REST APIs, some of which are reliable and well-established, while others may not be as reliable.

In certain scenarios, we cannot guarantee that the received model will precisely match the expected type. Some APIs may return different types for the same property. For instance, we might anticipate a Double value, but the API could provide a String or a Double value instead. How can our code effectively handle such situations? One approach is to implement init(from decoder: Decoder) throws functions for our structures, which require manual decoding of all values. While this is feasible for small structures, it becomes more challenging for larger ones, making it preferable to rely on automatically synthesized init.

So, how can we leverage synthesized init and effectively handle different types simultaneously? The answer lies in Property Wrappers! Yes, we can create a property wrapper that takes care of encoding and decoding JSON objects on our behalf.

In Swift, there exists a handy protocol called LosslessStringConvertible, which has a single requirement: init?(_description: String). Furthermore, all numeric types conform to this protocol! Consequently, we can devise a generic solution that handles all these types effortlessly.

@propertyWrapper
public struct LosslessStringCodable<T: LosslessStringConvertible & Codable> {
    private var value: T?

    public var wrappedValue: T? {
        get {
            return value
        }
        set {
            value = newValue
        }
    }
    
    public init(wrappedValue: T?) {
        value = wrappedValue
    }
}

The provided code introduces a property wrapper called LosslessStringCodable, designed to simplify the encoding and decoding of JSON objects in Swift. This property wrapper is defined as a generic type that requires its underlying value to conform to both the LosslessStringConvertible and Codable protocols.


extension LosslessStringCodable: Codable {
    public init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        if container.decodeNil() {
            value = nil
        } else if let string = try? container.decode(String.self) {
            value = T(string)
        } else if let decoded = try? container.decode(T.self) {
            value = decoded
        }
    }

    public func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        if let value = value {
            try container.encode("\(value)")
        } else {
            try container.encodeNil()
        }
    }
}

public extension KeyedDecodingContainer {
    func decode<T>(
        _ type: LosslessStringCodable<T>.Type,
        forKey key: Self.Key
    ) throws -> LosslessStringCodable<T> where T: Decodable & LosslessStringConvertible {
        return try decodeIfPresent(type, forKey: key) ?? LosslessStringCodable<T>(wrappedValue: nil)
    }
}

To make our property wrapper work seamlessly with encoding and decoding, we've extended it to conform to the Codable protocol. This allows us to take advantage of the powerful features provided by Swift.

When it comes to decoding, we've implemented the init(from:) initializer. Inside this initializer, we create a single value container from the decoder. First, we check if the container is nil. If it is, we assign nil to the value. However, if the container has a value, we try to decode a String from it. If the decoding is successful, we initialize the value with the converted String using T's initializer, because it conforms to the LosslessStringConvertible. But if that doesn't work, we attempt to directly decode T from the container.

For encoding, we've got the encode(to:) function. This function creates a single-value container from the encoder. We then check if the value is non-nil. If it is, we encode it as a String. But if the value is nil, we encode a nil value.

Additionally, we've got you covered with an extension for KeyedDecodingContainer. It includes a custom decoding function called decode(:forKey:), specifically designed for the LosslessStringCodable type. This function decodes a LosslessStringCodable<T> value for the specified key. It tries to decode the value for the key using the decodeIfPresent(:forKey:) function, which returns an optional value. If the value is present, we return it. Otherwise, we create a new LosslessStringCodable<T> instance with a nil value and hand it back to you.

Now we can see it in action:

struct Example: Codable {
    @LosslessStringCodable
    var stringValue: Int?

    @LosslessStringCodable
    var intValue: Int?

    @LosslessStringCodable
    var nilValue: Int?
}

let jsonString = """
{
  "stringValue": "123",
  "intValue": 456,
  "nilValue": null
}
"""

let jsonData = jsonString.data(using: .utf8)!

do {
    let example = try JSONDecoder().decode(Example.self, from: jsonData)
    print(example.stringValue) // Output: Optional(123)
    print(example.intValue) // Output: Optional(456)
    print(example.nilValue) // Output: nil
} catch {
    print("Error decoding JSON: \(error)")
}

By utilizing the LosslessStringCodable property wrapper, we simplify the encoding and decoding process for JSON objects. This allows us to harness the powerful capabilities of Swift's Codable protocol and the flexibility of the LosslessStringConvertible protocol, making it effortless to handle different types. It's a win-win situation for developers!

Artur Gruchała

Artur Gruchała

I started learning iOS development when Swift was introduced. Since then I've tried Xamarin, Flutter, and React Native. Nothing is better than native code:)
Poland