Use `self =` in convenience initializers to delegate to JSONDecoder or factory methods in Swift to avoid `Cannot assign to value: 'self' is immutable`
Asked Answered
C

1

0

Sometimes in Swift, it may be convenient to write an initializer for a class which delegates to JSONDecoder or a factory method. For example, one might want to write

final class Test: Codable {
    let foo: Int
    
    init(foo: Int) {
        self.foo = foo
    }
    
    func jsonData() throws -> Data {
        try JSONEncoder().encode(self)
    }
    
    convenience init(fromJSON data: Data) throws {
        self = try JSONDecoder().decode(Self.self, from: data)
    }
}

let test = Test(foo: 42)
let data = try test.jsonData()
let decodedTest = try Test(fromJSON: data)
print(decodedTest.foo)

but this fails to compile with

Cannot assign to value: 'self' is immutable.

What is the solution to work around this problem?

Certificate answered 1/6, 2021 at 0:26 Comment(4)
"static func init".Quintilla
Fair point. If you want to make that an answer, I will upvote. There are still issues to that approach though. Trivially, you can't name it init, but more relevantly, you may not like factory methods, or you may need it to be an initializer for protocol conformance reasons.Certificate
I don’t like it but I still use it for now. Wrap init in backticks.Quintilla
Oh, haha, I was confused why only part of that was rendering as code.Certificate
C
5

First, note that this limitation exists only for classes, so the example initializer will work for as-is for structs and enums, but not all situations allow changing a class to one of these types.

This limitation on class initializers is a frequent pain-point that shows up often on this site (for example). There is a thread on the Swift forums discussing this issue, and work has started to add the necessary language features to make the example code above compile, but this is not complete as of Swift 5.4. From the thread:

Swift's own standard library and Foundation overlay hack around this missing functionality by making classes conform to dummy protocols and using protocol extension initializers where necessary to implement this functionality.

Using this idea to fix the example code yields

final class Test: Codable {
    let foo: Int
    
    init(foo: Int) {
        self.foo = foo
    }
    
    func jsonData() throws -> Data {
        try JSONEncoder().encode(self)
    }
}

protocol TestProtocol: Decodable {}
extension Test: TestProtocol {}
extension TestProtocol {
    init(fromJSON data: Data) throws {
        self = try JSONDecoder().decode(Self.self, from: data)
    }
}

let test = Test(foo: 42)
let data = try test.jsonData()
let decodedTest = try Test(fromJSON: data)
print(decodedTest.foo)

which works fine. If Test is the only type conforming to TestProtocol, then only Test will get this initializer.

An alternative is to simply extend Decodable or another protocol to which your class conforms, but this may be undesirable if you do not want other types conforming to that protocol to also get your initializer.

Certificate answered 1/6, 2021 at 0:26 Comment(2)
You are not giving the option to use a custom decoder as shown here. This would be really limited if you don't allow the user to use a custom DateDecodingStrategy or keyDecodingStrategyBakemeier
Absolutely! Your solution is better for the specific use in that question. This is just an example of a pattern that could be used more generally than for just JSONDecoder, so I didn't want to have any extraneous details.Certificate

© 2022 - 2025 — McMap. All rights reserved.