Two more examples of Encoding and Decoding Custom Types in Swift
Overview
Swift provides a Foundation API to serialise from and to JSON “by implementing the Encodable
and Decodable
protocols on your custom types”.
The team at Apple has also written a great article about Encoding and Decoding Custom Types that provides a few examples to get you started and a few ones that require “a custom implementation of Encodable and Decodable”. I want to add two more examples that may not be immediately obvious after you read the article.
Both examples build upon the knowledge you have on encoding and decoding custom types from the article above. So consider that a prerequisite. The first one shows how to decode only part of a JSON document. This can be useful when working with an API where you are only interested in part of the response.
The second example is useful typically in an API that uses JSON to encode a “data” or “error” response. In this case, the HTTP response code is typically 200 OK
yet the actual JSON response communicates whether the request has been successful, returning the data requested or unsuccessful in which case an error is returned in the JSON payload.
Decode only part of a JSON document
{
"user": "qnoid",
"created_at": "2021-01-19T18:47:01+0200",
"text_entries": [{
"text": "Hello!"
},
{
"text": "¡Hola!"
}
]
}
In the example above, we are only interested in the text entries returned and have no need for the rest of the data. Consider an Entry
type describing each text entry that stores the “text” and a TextEntries
type that holds the Array of entries:
struct Entry: Codable {
let text: String
}
struct TextEntries {
enum Key: String, CodingKey {
case entries = "text_entries"
}
let values: [Entry]
}
In the example below, the TextEntries
type is extended to conform to the Decodable
protocol by implementing its required initialiser, init(from:)
:
extension TextEntries: Decodable {
init(from decoder: Decoder) throws {
let response = try decoder.container(keyedBy: Key.self)
var entries = try response.nestedUnkeyedContainer(forKey: .entries)
var values = [Entry]()
while !entries.isAtEnd {
let entry = try entries.decode(Entry.self)
values.append(entry)
}
self.values = values
}
}
The TextEntries
type can now be decoded using the JSONDecoder
:
let entries = try JSONDecoder().decode(TextEntries.self, from: data)
Let’s dymistify. JSONDecoder
uses the initialiser in the TextEntries
type to decode the data. In our case, the root object in the JSON is a collection of name/value pairs 1. The try decoder.container(keyedBy: Key.self)
“returns the data stored in this decoder as represented in a container keyed by the given key type.”. In simple terms, the code will try to interpret the root object as a dictionary where its keys are typed by the TextEntries.Key
enum type. The TextEntries.Key
defines only one key present, namely the case entries
(“text_entries” in JSON), as this is the only one we are interested in. The response
variable represents that dictionary.
Next, we want to extract the array of text entries. Hence the call to nestedUnkeyedContainer(forKey:)
which returns an UnkeyedDecodingContainer
type. You can think of the UnkeyedDecodingContainer
as an iterator where every call to decode()
decodes the type at the current index, starting at 0, then increments the index. When the iterator is exhausted, isAtEnd
will return false.
At the end of the initialiser, the values
are initialised with every entry in the “text_entries” Array and a TextEntries
instance is returned.
entries.values.map { entry in
entry.text
}
Decode a custom success/error JSON response
{
"success": true,
"contents": {
"text_entries": [{
"text": "Hello!",
},
{
"text": "¡Hola!",
}
]
}
}
{
"success": false,
"error": {
"code": 1,
"message": "User not found"
}
}
In this case you have two possible JSON responses thus the decision as to which one to decode must be done at runtime. One is considered a success with the “contents” (or “payload”) holding the data you are interested in. The other one is a failure that includes an “error”. The Result
type is ideal to describe this “exclusive or” truth and the init(catching:)
initialiser is fitting in trying to decode a successful response or throw the returned error in case of a failure. Let’s see how that looks in practice.
Consider the Error
type that stores the “code” and “message”:
struct Error: Codable {
enum Key: String, CodingKey {
case error
}
let code: Int
let message: String
init(from decoder: Decoder) throws {
let response = try decoder.container(keyedBy: Key.self)
let contents = try response.nestedContainer(keyedBy: CodingKeys.self, forKey: .error)
self.code = try contents.decode(Int.self, forKey: .code)
self.message = try contents.decode(String.self, forKey: .message)
}
}
The Error
type can now be decoded using the JSONDecoder
:
let error = try JSONDecoder().decode(Error.self, from: data)
Now consider the Contents
type with an “entries” property of type TextEntries
:
struct Contents {
enum Key: String, CodingKey {
case contents
}
enum CodingKeys: String, CodingKey {
case entries = "text_entries"
}
let entries: TextEntries
}
extension Contents: Decodable {
init(from decoder: Decoder) throws {
let response = try decoder.container(keyedBy: Key.self)
let contents = try response.nestedContainer(keyedBy: CodingKeys.self, forKey: .contents)
self.entries = try contents.decode(TextEntries.self, forKey: .entries)
}
}
As a reminder, the Contents
is at the root object in the JSON data. First, we obtain the container as keyed by the “contents” key, followed by the “text_entries” as the TextEntries
type:
struct TextEntries {
let values: [Entry]
}
extension TextEntries: Decodable {
init(from decoder: Decoder) throws {
var entries = try decoder.unkeyedContainer()
var values = [Entry]()
while !entries.isAtEnd {
let entry = try entries.decode(Entry.self)
values.append(entry)
}
self.values = values
}
}
Notice how in this case the TextEntries
decoding is part of the Contents
decoding hence the call to unkeyedContainer()
as this is done under the “text_entries” key2. The Contents
type can now be decoded using the JSONDecoder
:
let contents = try JSONDecoder().decode(Contents.self, from: data)
contents.entries.values.map { entry in
entry.text
}
Before we can write the closure expected by init(catching:)
for the Result
type, the Error
type must be declared as a Swift.Error
that we can throw
:
extension Error: Swift.Error, CustomNSError {
public static var errorDomain: String = "com.qnoid"
var errorCode: Int {
return code
}
var errorUserInfo: [String : Any] {
[NSLocalizedDescriptionKey : message]
}
}
With both Content
and Error
types defined, here is how we can now construct a Result
.
let result = Result<Contents, Swift.Error> {
do {
return try JSONDecoder().decode(Contents.self, from: data)
} catch {
//log `error` in the catch block
throw try JSONDecoder().decode(Error.self, from: data)
}
}
Effectively, the closure will try to decode the Contents
type. In case that fails, there is an assumption3 that instead this is a failure response and the included Error
is thrown. Keep in mind that the try
to decode the Error
might fail, in which case the Result
type will throw the Swift.Error
as thrown by the decode(from:)
function.
This is the calling code:
do {
let contents = try result.get()
contents.entries.values.map { entry in
entry.text
}
}
catch {
error
}
Resources
Related
- Using Swift Result and flatMap “Create A Result From A Throwing Expression”