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:
- Add Firestore
Codable
to your project - Make your models
Codable
- Use the new methods to retrieve / store data
- (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 id
s 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.