Having trouble with RawRespresentable "Expected to decode String but found a dictionary instead."

I want to use AppStorage for a custom struct I am using

struct Activities {
    var name: String
    var age: Int
}
struct ContentView: View {
    @AppStorage("key") private var activities: Activities = .init(name: "Albert", age: 42)

    var body: some View {
        VStack {
            TextField("Activity Name", text: $activities.name)
        }
    }
}

The above code generates a compiler warning, recommending I add RawRepresentable conformance. So I've added it like this:

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

This leads to a stack overflow because calling encode from rawValue calls rawValue. :-( I overcame this by declaring Codable conformance and overriding the default Encodable implementation:

extension Activities: Codable {
    enum CodingKeys: String, CodingKey {
        case name
        case age
    }
    func encode(to encoder: any Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(name, forKey: .name)
        try container.encode(age, forKey: .age)
    }
}

This solves the stack overflow, but now init?(rawValue: String) is failing and I'm not sure why. When I set a breakpoint in my catch block I see the following:

(lldb) po error
▿ DecodingError
  ▿ typeMismatch : 2 elements
    - .0 : Swift.String
    ▿ .1 : Context
      - codingPath : 0 elements
      - debugDescription : "Expected to decode String but found a dictionary instead."
      - underlyingError : nil

(lldb) po rawValue
{"name":"Albert2","age":42}
(lldb) po data
▿ 27 bytes
  - count : 27
  ▿ bytes : 27 elements
    - 0 : 123
    - 1 : 34
    - 2 : 110
    - 3 : 97
    - 4 : 109
    - 5 : 101
    - 6 : 34
    - 7 : 58
    - 8 : 34
(truncated to save space for posting :-)
Answered by DTS Engineer in 886205022

I’m not 100% sure what’s causing all the behaviour you’re seeing [1] but if I were in your shoes I’d get around this by declaring an codable version of your struct solely for the benefit of the coding system:

struct Activities {
    var name: String
    var age: Int
    
    fileprivate struct Inner: Codable {
        var name: String
        var age: Int
    }
}

extension Activities: RawRepresentable {

    init?(rawValue: String) {
        guard let i = try? JSONDecoder().decode(Inner.self, from: Data(rawValue.utf8)) else {
            return nil
        }
        self.init(name: i.name, age: i.age)
    }
    
    var rawValue: String {
        let i = Inner(name: self.name, age: self.age)
        let e = try! JSONEncoder().encode(i)
        return String(decoding: e, as: UTF8.self)
    }
}

The downside is that you need to keep Activities and Activities.Inner in sync, but the benefit is that the extra code is super simple.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] I think it’s because the encoding system has special cases for types with a raw value, but I haven’t actually dug into that properly.

Accepted Answer

I’m not 100% sure what’s causing all the behaviour you’re seeing [1] but if I were in your shoes I’d get around this by declaring an codable version of your struct solely for the benefit of the coding system:

struct Activities {
    var name: String
    var age: Int
    
    fileprivate struct Inner: Codable {
        var name: String
        var age: Int
    }
}

extension Activities: RawRepresentable {

    init?(rawValue: String) {
        guard let i = try? JSONDecoder().decode(Inner.self, from: Data(rawValue.utf8)) else {
            return nil
        }
        self.init(name: i.name, age: i.age)
    }
    
    var rawValue: String {
        let i = Inner(name: self.name, age: self.age)
        let e = try! JSONEncoder().encode(i)
        return String(decoding: e, as: UTF8.self)
    }
}

The downside is that you need to keep Activities and Activities.Inner in sync, but the benefit is that the extra code is super simple.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] I think it’s because the encoding system has special cases for types with a raw value, but I haven’t actually dug into that properly.

It’s better to reply as a reply, rather than in the comments; see Quinn’s Top Ten DevForums Tips for this and other titbits.

No need to keep an Inner synced to Activities at run time right?

Correct.

Consider what happens if you need to added a wombat field to Activities:

  • You have to add matching field to Activities.Inner.
  • And change init?(rawValue:) to pass that value to the Activities initialiser.
  • And change rawValue to pass the Activities value to the Activities.Inner initialiser.

It’s not complex, it’s just bookkeeping.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Having trouble with RawRespresentable "Expected to decode String but found a dictionary instead."
 
 
Q