Sometimes fixing issues in your codebase can have side effects you never thought of. You assume everything that could go wrong was considered but then once merged, weird things happen. I exactly had this last week. I was fixing a bug about double protocol conformance which in the end led to the application behaving weirdly and not recognizing data that was saved beforehand.
In this small article, I want to share what I learned from this.
What is Double Protocol Conformance?
Double Protocol Conformance occurs when a type is declaring conformance to a protocol multiple times. For example, a type which conforms to Codable
is conforming to the same protocol again in an extension.
public struct Item: Codable { }
// Error: Redundant conformance of 'Item' to protocol 'Decodable'
// Error: Redundant conformance of 'Item' to protocol 'Encodable'
extension Item: Codable { }
If we declare the conformance to a protocol in the same target multiple times, as seen above, Xcode will throw an error and will not build the target.
The Tricky Part
So far so good. When accidentally declaring the same protocol conformance in the same target, Xcode has our back and will stop compiling our project. The interesting part comes when you declare the same conformance in a different target. Then things are not that obvious any longer. Let's use the example from above.
We declare an Item
type in our TargetA
which conforms to Codable
and does not implement custom decoding or encoding and rather uses the auto-generated Codable
conformance by the Swift compiler.
// TargetA
public struct Item: Codable {
public let id: UUID
public let name: String
public init(id: UUID, name: String) {
self.id = id
self.name = name
}
}
Secondly, we declare an extension on Item
in TargetB
where TargetA
is imported. Now, custom methods for encoding and decoding are implemented. In this example with different coding key names.
// TargetB
import Foundation
import TargetA
extension Item: Codable {
// MARK: - Coding Keys
enum CodingKeys: String, CodingKey {
case id = "product_id"
case name = "product_name"
}
// MARK: - Encodable
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
}
// MARK: - Decodable
public init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
let id = try values.decode(UUID.self, forKey: .id)
let name = try values.decode(String.self, forKey: .name)
self = .init(id: id, name: name)
}
}
Xcode is not reporting an error any longer, instead, it reports a warning which tells us the type we are extending already conforms to the Codable
protocol.
In regular cases, this is still quite obvious for one to see when opening Xcode. But sometimes warnings are overseen and since the code compiles fine, one might think everything is good. This can be misleading, which the following issue shows us.
The Bug
Last week I found the warning of double protocol conformance in our codebase and wanted to fix it by moving the custom encoding and decoding methods to TargetA
. This was easy I thought.
// TargetA
public struct Item: Codable {
public let id: UUID
public let name: String
public init(id: UUID, name: String) {
self.id = id
self.name = name
}
}
extension Item: Codable {
// MARK: - Coding Keys
enum CodingKeys: String, CodingKey {
case id = "product_id"
case name = "product_name"
}
// MARK: - Encodable
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
}
// MARK: - Decodable
public init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
let id = try values.decode(UUID.self, forKey: .id)
let name = try values.decode(String.self, forKey: .name)
self = .init(id: id, name: name)
}
}
Wrong thought. The tricky thing now is, that the app where this bug was fixed, was already in production and had data saved to disk, encoded with the old implementation. Meaning with the double protocol conformance issue. I was under the assumption, that when double protocol conformance is occurring, the second, overridden implementation is going to be used. I was wrong! Actually what is going to be used is the first conformance to the protocol.
This led to the following issue. If we assume we have our example from the beginning. Before the "fix", the data was saved using the autogenerated Codable
methods, leading to a JSON
representation like the following:
{
"id": "971C88C9-1A49-4837-88AB-D049ACD28F55",
"name": "My Item"
}
After the "fix", the code was expecting a JSON
representation in the following format:
{
"product_id": "149CF891-1DF1-4C2D-A95A-E63FBE5BD91E",
"product_name": "My Item"
}
We can already see the keys are different. And since I didn't implement any kind of migration logic, the decoding process failed and the application was not able to read the saved data any longer.
In the end, I simply removed the custom decoding and encoding logic, since it was anyway not used by the application and fully relied on the autogenerated implementation in TargetA
.
If I would have needed the custom implementation, it would have been necessary to write a migration logic to fix the old keys and from then on use the new keys from the custom implementation.
The Extreme
We can even bring the issue to an extreme, which would have made it even harder to debug. Thanks a lot to Rob who raised awareness of this! Think of having three targets now:
- TargetA: Declares the
Item
type and does not conform toCodable
- TargetB: Depends on
TargetA
and extends conformance toCodable
with a custom implementation - TargetC: Depends on
TargetA
but not onTargetB
and extends conformance toCodable
with a custom implementation as well.
Now, Xcode is not even bringing up warnings again. Nothing shows us that we made a mistake in setting up our protocol conformance. If we would now import all of these targets in another one, it depends on which order the frameworks are imported and the behaviour would change without us knowing upfront:
Let's have a look at the following snippet:
import TargetA
import TargetB
import TargetC
let item = Item(id: .init(), name: "My Item")
let encoder = JSONEncoder()
let encoded = try encoder.encode(item)
let jsonString = String(data: encoded, encoding: .utf8)
This would lead to the following JSON
representation:
{
"product_name_B": "My Item",
"product_id_B": "42A18A37-51FA-422C-9BB9-46D382CDF1CA"
}
Whereas if the import order would have been:
import TargetA
import TargetC
import TargetB
The resulting JSON
looks like this:
{
"product_name_C": "My Item",
"product_id_C": "42A18A37-51FA-422C-9BB9-46D382CDF1CA"
}
This fact makes it even harder to detect issues. Especially in a heavily modularized codebase where multiple teams are working on the codebase, where it could be hard to keep track of which protocols are getting retroactively added to public types.
Conclusion
I hope with this small story I was able to show you the tricky parts of having double protocol conformance in multiple targets. Always pay attention to warnings when you conform a type to a protocol and remember that only the first conformance is going to be used. Any subsequent conformances will be ignored by the compiler.
If you are working with multiple developers in a codebase, make sure you set up rules for your protocol conformances to avoid the extreme case we took a look at. This is crucial to avoid surprises.
Let me know what you think about this post and if you have any feedback don't hesitate to reach out to me!
See you next time! 👋