Exploring Swift’s Codable Protocol
In the previous article, we explored techniques for modifying key names when working with the Codable protocol in Swift. This article will focus on customizing the values within JSON data. The desired transformations can be accomplished by implementing custom encode/decode methods. Specifically, within these methods, we can leverage the Keyed Container, Unkeyed Container, and Single Value Container types to handle encoding values into and decoding values from JSON data. In this article, we will explore the Keyed Container, leaving the remaining types to be covered in subsequent articles.
The Keyed Container is responsible for handling object data in JSON. There are several approaches we can take to inject custom logic into the encoding and decoding processes.
Customise encode/decode methods
Consider the following Person struct as an example, which has two properties, firstName and lastName. In the customized encode method, these two values are encoded into a single JSON field called name. Conversely, during the decoding process, the decode method constructs a Person instance from the value of the name JSON field.
struct Person: Codable {
let firstName: String
let lastName: String
init(firstName: String, lastName: String) {
self.firstName = firstName
self.lastName = lastName
}
enum CodingKeys: String, CodingKey {
case name
}
func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(firstName + " " + lastName, forKey: .name)
}
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let value = try container.decode(String.self, forKey: .name)
let components = value.split(separator: " ")
guard components.count == 2,
let firstName = components.first,
let lastName = components.last else {
throw DecodingError
.dataCorrupted(.init(codingPath: [CodingKeys.name],
debugDescription: "Wrong value format"))
}
self.firstName = String(firstName)
self.lastName = String(lastName)
}
}
With the custom encoding and decoding logic in place, if we encode a Person instance, for example, Person(firstName: "Tim", lastName: "Wang"), the resulting JSON string will be {"name":"Tim Wang"}. Conversely, this JSON string can be decoded back into a Person instance using the custom decoding logic.
let person = Person(firstName: "Tim", lastName: "Wang")
let data = try JSONEncoder().encode(person)
let jsonString = String(data: data, encoding: .utf8)
let newPerson = try JSONDecoder().decode(Person.self, from: data)
Inject configurations
In the previous example, we hardcoded specific logic within the encoding and decoding methods. The CodableConfiguration property wrapper offers an approach to inject configurations into this process to make it more flexible. In the following example, we leverage this wrapper to inject the TruncatedStringCodableConfiguration during the encoding and decoding process. This configuration is used to truncate string values, with the maximum length being configurable through the TruncatedString type.
struct Person: Codable {
let firstName: String
let lastName: String
}
struct Message: Codable {
let sender: Person
@CodableConfiguration(from: TruncatedString.self) var content: String = ""
}
struct TruncatedString: DecodingConfigurationProviding, EncodingConfigurationProviding {
static var decodingConfiguration: TruncatedStringCodableConfiguration {
TruncatedStringCodableConfiguration(maxLength: 140)
}
static var encodingConfiguration: TruncatedStringCodableConfiguration {
TruncatedStringCodableConfiguration(maxLength: 140)
}
}
@available(macOS 12, iOS 15, tvOS 15, watchOS 8, *)
extension String: CodableWithConfiguration {
public init(from decoder: any Decoder, configuration: TruncatedStringCodableConfiguration) throws {
let container = try decoder.singleValueContainer()
self = try String(container.decode(String.self).prefix(configuration.maxLength))
}
public func encode(to encoder: any Encoder, configuration: TruncatedStringCodableConfiguration) throws {
var container = encoder.singleValueContainer()
try container.encode(self)
}
}
public struct TruncatedStringCodableConfiguration {
let maxLength: Int
public init(maxLength: Int) {
self.maxLength = maxLength
}
}
let message = Message(sender: .init(firstName: "Tim", lastName: "Wang"), content: "How are you?")
let messageData = try JSONEncoder().encode(message)
let newMessage = try JSONDecoder().decode(Message.self, from: messageData)
Other considerations
Handling optional JSON fields
In JSON, some fields can be optional. To handle these cases, the KeyedEncodingContainer and KeyedDecodingContainer types in Swift provide a group of methods. When dealing with optional fields, we have optional properties and keys corresponding to these JSON fields. For example, consider the following JSON string and Person struct:
let json =
"""
{"firstName": "Tim"}
"""
struct Person: Codable {
let firstName: String
let lastName: String? // Optional
}
In this example, the lastName field is optional in the Person struct, as it may or may not be present in the JSON data. The corresponding key "lastName" in the JSON object is also optional.
If we run let person = try JSONDecoder().decode(Person.self, from: json.data(using: .utf8)!), it will return a Person instance with the name value set to "Tim", the lastName value set to nil. This behavior is achieved due to the way the Swift compiler generates the decode method for the Person struct. The generated decode method utilizes the decodeIfPresent method to handle optional fields. If we were to change it to use the decode method instead, it would throw an error if the lastName field is missing from the JSON data, as it expects the field to be present. Here’s an example of how the generated decode method might look like:
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.firstName = try container.decode(String.self, forKey: .firstName)
self.lastName = try container.decodeIfPresent(String.self, forKey: .lastName)
}
When dealing with optional properties during the encoding and decoding process, Swift provides additional methods to handle nil values explicitly. If you want to set a property to nil during the decoding process, you can use the decodeNil method provided by the KeyedDecodingContainer. This method allows you to differentiate between a missing field and an explicitly set null value in the JSON data.
Similarly, during the encoding process, you can use the encodeNil method provided by the KeyedEncodingContainer to generate a null value in the JSON output for a property that is nil. This behavior differs from the encodeIfPresent method, which omits the field entirely from the JSON output if the value is nil.
By leveraging these specialized methods, you gain granular control over how optional properties are handled during the encoding and decoding processes, ensuring accurate representation of nil values in the JSON data.
Tweak structure
When writing custom encode and decode methods, there may be situations where you need to access values that are nested within the JSON data structure. For example, consider the following JSON data:
let json =
"""
{
"name":
{
"firstName": "Tim"
}
}
"""
In this case, the firstName propertie is part of the nested name object. While it’s common to decode these values within a separate Name struct, there may be cases where you need to access them directly from the parent object.
To handle such scenarios, Swift provides the nestedContainer and nestedUnkeyedContainer methods on the KeyedDecodingContainer and UnkeyedDecodingContainer, respectively. These methods allow you to navigate into nested containers, enabling you to access and decode values at different levels of the JSON hierarchy.
let json =
"""
{
"name":
{
"firstName": "Tim"
}
}
"""
struct Name: Codable {
let firstName: String
let lastName: String?
enum CodingKeys: String, CodingKey {
case firstName
case lastName
}
}
struct Person: Codable {
let name: Name
init(name: Name) {
self.name = name
}
enum CodingKeys: String, CodingKey {
case name
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// Use nestedContainer to access firstName here
let nestedContainer = try container.nestedContainer(keyedBy: Name.CodingKeys.self, forKey: .name)
print(try nestedContainer.decode(String.self, forKey: .firstName))
self.name = try container.decode(Name.self, forKey: .name)
}
}
Similarly, the KeyedEncodingContainer provides the nestedContainer and nestedUnkeyedContainer methods, which enable encoding nested values directly at the parent level. These methods offer flexibility and control over the encoding process, allowing developers to customize the behavior as needed, even when dealing with nested data structures. Consider the following example:
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
var nested = container.nestedContainer(keyedBy: Name.CodingKeys.self, forKey: .name)
try nested.encode(name.firstName, forKey: .firstName)
try nested.encodeIfPresent(name.lastName, forKey: .lastName)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.name, forKey: .name)
}
In the first encode method, the nestedContainer(keyedBy:forKey:) method is used to create a nested KeyedEncodingContainer for the name key. This nested container allows for encoding the firstName and lastName values directly at the parent level, even though they are properties of the nested Name struct. The encodeIfPresent method is used to conditionally encode the lastName property if it has a non-nil value.
The second encode method leverages the default Codable implementation for the Name struct. It encodes the entire name instance directly using the encode(_:forKey:) method, relying on the conformance of the Name struct to the Codable protocol.
When the Name struct uses the default Codable implementation, both encoding methods will produce the same JSON output. However, the first method provides greater control over the encoding process, allowing for customizations such as conditionally encoding properties or applying additional logic during encoding.
Conclusion
In this article, we delved into the techniques of customizing the encode and decode methods using the various methods provided by the KeyedContainer types in Swift’s Codable protocol. By encapsulating the mapping logic within custom encode and decode methods, we promote the separation of concerns and keep the logic contained within the appropriate layer of our application.
Encapsulating this logic offers several benefits. Firstly, it enhances code organization and maintainability by separating the concerns of data transformation from the higher-level business logic. This separation promotes a more modular and extensible codebase, as changes to the data mapping can be made without impacting other layers of the application.
Secondly, custom encode and decode methods provide greater control and flexibility over the serialization and deserialization processes. Developers can leverage methods such as nestedContainer, nestedUnkeyedContainer, decodeNil, and encodeNil to handle complex data structures, conditionally encode or decode properties, and apply custom logic as needed. This level of control is particularly valuable when working with nested data structures or when conforming to specific data formats.
Furthermore, by encapsulating the mapping logic within custom methods, higher-level layers of the application can seamlessly leverage the benefits of Swift’s concise and expressive syntax without concern for the underlying transformation details. This abstraction promotes code clarity and readability, as the higher-level logic can focus on the core business requirements rather than being burdened with low-level data transformation details.
Overall, customizing the encode and decode methods using the KeyedContainer methods in Swift’s Codable protocol empowers developers to create robust, maintainable, and extensible solutions for handling data serialization and deserialization. By embracing this approach, developers can effectively manage complex data structures, enhance code organization, and promote a clear separation of concerns within their applications.
Sharing is caring!