RawRepresentable's undeclared conformance with Codable yields infinite recursion when declared

Conforming a value type to Encodable without specifying explicit CodingKeys or conformance instructs the compiler to synthesize conformance for you. This is great:

struct S: Codable {
    var a = "a"
}

JSONEncoder().encode(S()) // {"a":"a"}

The synthesized conformance should ignore lazy and computed properties, which we can see it do:

struct S: Codable {
    var a = "a"
    lazy var b = "b"
    var c: String { "c" }
}

JSONEncoder().encode(S()) // {"a":"a"}

In order to conform to SwiftUI.AppStorage's requirements (types must conform to RawRepresentable, which has no synthesis mechanism), we conjure up a RawCodable:

public typealias RawCodable = Codable & RawRepresentable

public extension RawRepresentable where Self: Codable {
    init?(rawValue: String) {
        guard let data = rawValue.data(using: .utf8), let result: Self = try? JSONDecoder().decode(Self.self, from: data)
        else { return nil }
        
        self = result
    }
    
    var rawValue: String {
        guard let data = try? JSONEncoder().encode(self), let result = String(data: data, encoding: .utf8)
        else { return "{}" }
        
        return result
    }
}

S().rawValue // infinite recursion

Simple enough, however, when we run this, we notice rawValue triggers an infinite recursion during the encode. It's entirely unclear why this should be the case — is rawValue participating in Encodable? It really shouldn't, as it is a computed property.

Can someone help explain?

If you look at the documentation of RawRepresentable you see that it implements the Codable requirements:

init(from: any Decoder) throws
func encode(to: any Encoder) throws

for all the default types that conform to RawRepresentable. These default implementations read the rawValue property during encoding and use the init?(rawValue: String) initializer when decoding, so by writing an implementation of those methods that also call the JSONDecoders encode / decode inside that function will create an infinite recursion.

The implementation should look something like this:

func encode(to encoder: any Encoder) throws {
   encoder.singleValueContainer().encode(self.rawValue)
}

init(from decoder: any Decoder) throws {
   self.rawValue  = try decoder.singleValueContainer().decode(Self.RawValue.Type)
}
4 Likes

Thanks, that explains the recursion!

If I understand correctly, RawRepresentable's encode(to:) and init(from:) are inhibiting the compiler-synthesized variants of these functions from being installed per the type's Codable conformance.

Would there be any way to recover the compiler-synthesized variants and successfully make an arbitrary struct RawRepresentable through synthesized Codable conformance? Or am I going to have to conform each of my types manually?

So far, the only solution I've come up with is to isolate the type as purely Codable in a separate module and add the RawRepresentable conformance when importing the module.

I don't think so, the compiler will only synthesize these functions if no other implementation is provided, and here one is provided. Also there is a difference in how this is encoded (this is from memory, so test and verify)

struct Foo: Codable {
   let rawValue: Int
}

Foo(rawValue: 5) is encoded into JSON as {"rawValue": 5 }
but

struct Bar: RawRepresentable, Codable {
   let rawValue: Int
}

Bar(rawValue: 5) is encoded into JSON as { 5 }.

That is what this custom Codable implementation provides.


But back to your general question; I think you are going about this in the wrong way.

Anything that you give a conformance to RawRepresentable where the raw value is of type String, Bool, Double, Float, (U)Int, (U)Int8, (U)Int16, (U)Int32, (U)Int64 will gain an implementation of the requirements for Codable, and you can simply use that conformance. If you need a different behavior you should likely create your own protocol and not reuse RawRepresentable.

2 Likes