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?

deaton.dg
  • 1,282
  • 9
  • 21
  • "static func `init`". –  Jun 01 '21 at 00:42
  • 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. – deaton.dg Jun 01 '21 at 00:54
  • 1
    I don’t like it but I still use it for now. Wrap init in backticks. –  Jun 01 '21 at 01:41
  • 1
    Oh, haha, I was confused why only part of that was rendering as code. – deaton.dg Jun 01 '21 at 01:43

1 Answers1

4

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.

deaton.dg
  • 1,282
  • 9
  • 21
  • 1
    You are not giving the option to use a custom decoder as shown [here](https://stackoverflow.com/a/65617269/2303865). This would be really limited if you don't allow the user to use a custom [DateDecodingStrategy](https://developer.apple.com/documentation/foundation/jsondecoder/datedecodingstrategy) or [keyDecodingStrategy](https://developer.apple.com/documentation/foundation/jsondecoder/keydecodingstrategy/convertfromsnakecase) – Leo Dabus Jun 01 '21 at 00:56
  • 1
    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. – deaton.dg Jun 01 '21 at 01:00