SwiftUI: Mapping Firestore Documents using Swift Codable

SwiftUI: Mapping Firestore Documents using Swift Codable

Last time, we looked at how to connect a SwiftUI app to a Firebase project and synchronise data in real time. If you take a look at the code we used for mapping from Firestore documents to our Swift model structs, you will notice that is has a number of issues:

self.books = documents.map { queryDocumentSnapshot -> Book in
  let data = queryDocumentSnapshot.data()
  let title = data["title"] as? String ?? ""
  let author = data["author"] as? String ?? ""
  let numberOfPages = data["pages"] as? Int ?? 0

  return Book(id: .init(), title: title, author: author, numberOfPages: numberOfPages)
}
  • It is rather verbose - even for just the few attributes we've got!
  • The code makes some assumptions about the structure of the documents, such as the attribute types
  • Things might start breaking if we change the structure of the documents or the struct holding our model

So in this post, I'm going to show you how to make your mapping code more concise, less error-prone, and more maintainable by using the Codable protocol.

What is Codable?

The Codable protocol is Apple's contribution to a standardised approach to encoding and decoding data. If you've been a Swift developer for a while, you might recall that, before Apple introduced Codable in Swift 4, you had to perform data mapping from and to external representations either manually or by importing third-party libraries. None of this is required anymore, thanks to Codable.

For a long time, Firestore lacked support for Codable, and the GitHub issue asking for adding support for object serialization in Firestore (#627) might be one of the most popular / upvoted issues of Firebase iOS SDK so far.

The good news is that, as of October 2019, Firestore provides support for Codable, and it's every bit as easy to use as you might hope it is. It essentially boils down to three steps:

  1. Add Firestore Codable to your project
  2. Make your models Codable
  3. Use the new methods to retrieve / store data
  4. (Bonus!) delete all of your existing mapping code

Let's look at these a little bit closer.

Prepare your project

As Firestore's Codable support is only available for Swift, it lives in a separate Pod, FirebaseFirestoreSwift - add this to your Podfile and run pod install.

Make your models Codable

Import FirebaseFirestoreCodable in the file(s) holding your model structs, and implement Codable, like so:

struct Book: Identifiable, Codable {
  @DocumentID var id: String?
  var title: String
  var author: String
  var numberOfPages: Int
  // ...
}

As we want to use the Book models in a ListView, they need to implement Identifiable, i.e. they need to have an id attribute. If you've worked with Firestore before, you know that each Firestore document has a unique document ID, so we can use that and map it to the id attribute. To make this easier, FirebaseFirestoreSwift provides a property wrapper, @DocumentID, which tells the Firestore SDK to perform this mapping for us.

If the attribute names on your Firestore documents match with the property names of your model structs, you're done now.

However, if the attribute names differ, as they do in our example, you need to provide instructions for the Encoder / Decoder to map them correctly. We can do so by providing a nested enumeration that conforms to the CodingKey protocol.

In our example, the name of the attribute that contains the number of pages of a book is called pages in our Firestore documents, but numberOfPages in our Book struct. Let's use the CodingKeys to map them to each other:

import Foundation
import FirebaseFirestoreSwift

struct Book: Identifiable, Codable {
  @DocumentID var id: String?
  var title: String
  var author: String
  var numberOfPages: Int

  enum CodingKeys: String, CodingKey {
    case id
    case title
    case author
    case numberOfPages = "pages"
  }
}

It is important to note that once you use CodingKeys you'll have to explicitly provide the names of all attributes you want to map. So if you forget to map the id attribute, the ids of your model instances will be nil. This will result in unexpected behaviour, for example when trying to display them in a ListView. Check out Apple's documentation for a more detailed discussion of Codable.

Fetching data

Now that our model is prepared for mapping, we can update the existing mapping code on our view model. At the moment, it looks like this:

func fetchData() {
  db.collection("books").addSnapshotListener { (querySnapshot, error) in
    guard let documents = querySnapshot?.documents else {
    print("No documents")
    return
  }

  self.books = documents.map { queryDocumentSnapshot -> Book in
    let data = queryDocumentSnapshot.data()
    let title = data["title"] as? String ?? ""
    let author = data["author"] as? String ?? ""
    let numberOfPages = data["pages"] as? Int ?? 0

    return Book(id: .init(), title: title, author: author, numberOfPages: numberOfPages)
  }
}

Time to delete some code and simplify this!

We can replace the mapping code in the documents.map closure with a much simpler version:

func fetchData() {
  db.collection("books").addSnapshotListener { (querySnapshot, error) in
    guard let documents = querySnapshot?.documents else {
      print("No documents")
      return
    }

    self.books = documents.compactMap { queryDocumentSnapshot -> Book? in
      return try? queryDocumentSnapshot.data(as: Book.self)
    }
  }
}

As you can see, we got rid of the entire process of manually reading attributes from the data dictionary, performing typecasts, and providing default values. Our code is a lot safer now that the SDK takes care of this for us.

All of this is thanks to the data(as:) method, which is provided by the FirebaseFirestoreSwift module. Doesn't it feel great to remove all this code?

Writing data

So we've covered mapping data when reading it from Firestore - what about the opposite direction?

It turns out that this is almost as simple as reading data - let's take a quick look. To write data, we can use use addDocument(from:) instead of .addDocument(data:):

func addBook(book: Book) {
  do {
    let _ = try db.collection("books").addDocument(from: book)
  }
  catch {
    print(error)
  }
}

This saves us from writing the mapping code, which means a lot less typing, and - more importantly - a lot less opportunity to get things wrong and introduce bugs. Great!

Advanced Features

With the basics under our belt, let's take a look at a couple of more advanced features.

We already used the @DocumentID property wrapper to tell Firestore to map the document IDs to the id attribute in our Book struct. There are two other property wrappers you might find useful: @ExplicitNull and @ServerTimestamp.

If an attribute is marked as @ExplicitNull, Firestore will write the attribute into the target document with a null value. If you save a document with an optional attribute that is nil to Firestore and it is not marked as @ExplicitNull, Firestore will just omit it.

@ServerTimestamp is useful if you want need to handle timestamps in your app. In any distributed system, chances are that the clocks on the individual systems are not completely in sync all of the time. You might think this is not a big deal, but imagine the implications of a clock running slightly out of sync for a stock trade system: even a millisecond deviation might result in a difference of millions of dollars when executing a trade. Firestore handles attributes marked with @ServerTiemstamp as follows: if the attribute is nil when you store it (using addDocument, for example), Firestore will populate the field with the current server timestamp at the time of writing it into the database. If the field is not nil when you call addDocument() or updateData(), Firestore will leave the attribute value untouched. This way, it is easy to implement fields like createdAt and lastUpdatedAt.

Where to go from here

Today, you learned how to simplify your data mapping code by using the Codable protocol with Firestore. Next time, we're going to take a closer look at saving, updating, and deleting data.

Thanks for reading, and make sure to check out the Firebase channel on YouTube.