Software Engineer

I am a Software Engineer. I have a Bachelor (Honours) of Science in Information Technology from the University of Sunderland - Class of 2003. I have been developing software since 2001 when I was offered a role at CERN as part of their Technical Student Programme.

By 2016 I had grown really tired of the software industry and by the end of 2019 Apple killed whatever excitement I had left. I am not sure what the next 10 years will bring. What I do know is that my apettite to do work that is impactful has only grown bigger and stronger. Great people make me tick more than anything.

I am also tired.

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


  1. As opposed to an ordered list of values. ↩︎

  2. This is a good hint that serialising a type is highly coupled with the decoding algorithm itself and should take into consideration. ↩︎

  3. Even though, this contract is well defined, consider logging the error thrown instead of silently ignoring it. ↩︎