Swifty Firebase APIs @ Ka-ching — part 2

Morten Bek Ditlevsen
Swift2Go
Published in
10 min readAug 9, 2018

Type-safe paths using Phantom Types

UPDATE, OCTOBER 14th, 2018:

Since writing this article, the concepts described in these blog posts have been made into two Open Source frameworks, which can be found here:

This post is a continuation of the 3 part series, that started with adding Codable support to the Firebase APIs:

In this post we will cover a very interesting and powerful subject which is hugely inspired by the objc.io Swift Talk #71: Type-Safe File Paths with Phantom Types.

In the Swift Talk, Brandon Kase and Florian Kugler discuss `Phantom Types` which in short are types that are never instantiated, but only ever used as generic constraints. These can be a really powerful tool for adding strong typing to APIs that are usually not strongly typed — just like the paths used in Firebase RTDB References.

Please, please go and watch the awesome talk on objc.io!

In this post we are going to mix Phantom Types with actual Codable model types to create a really powerful API.

Sample code for this post can be found on the typesafe_paths branch of the github repo introduced in the first blog post:

Note that this post deals with Firebase, but the techniques described can be used for any kind of API where you have a ‘stringly’ defined hierarchy of data.

A simple Path type

We are going to introduce a Path type that basically wraps an Array of path components, but adds type safety in the process.

As you can see, the Path is generic over some Element.

It carries an array of Strings representing the path components.

It has an append function that takes a variable number of strings (just for convenience, so that it can be called with one or multiple path components).

append is made fileprivate so only code in this file can append path components. Similarly, the init is fileprivate too, so by default it cannot be instantiated from the outside.

The only public member is rendered which returns the joined path components.

The fun part begins when we start adding constrained extensions to Path.

Constrained extensions

First of all we need to provide a way to initialize a Path to the outside world — since the only current init is fileprivate.

This will be the first example of a Phantom Type.

We simply create an empty enum called Root. Now we can extend the Path type constrained to the Root type as follows:

enum Root {}extension Path where Element == Root {
init() {
self.init([])
}
}

This means that we can only ever create fresh instances of the Path type if they are paths of type Root. Swift's type inference even helps us, so if we type:

let root = Path()

The type of root is now Path<Root>.

But this alone does not do us a whole lot of good, so let’s expand our example a bit:

Imagine a Firebase Real-time Database with a tree structure like this:

/
chatrooms
<chatroom key>
name
<chatroom name>
messages
<message key>
<message>
configuration
<some configuration entity>

Hopefully you can follow my arbitrary structure annotation:

At the root level we have a chatrooms path and a configuration path. Under chatrooms we have a number of keys of individual chat rooms. For each individual chat room we have a name and a messages path. Name could just be a String and under messages we have a number of keys — and for each of these keys we have some Message entity. The configuration path at the root contains some Configuration entity.

With the above structure in mind, now we can create yet another constrained extension on Root paths that lets us access all the children in the root of our tree:

enum Root {}
enum ChatRooms {}
struct Configuration: Codable {
// Our actual Configuration entity
var welcomeMessage: String
}
extension Path where Element == Root {
init() {
self.init([])
}
}
extension Path where Element == Root {
var chatrooms: Path<ChatRooms> {
return append("chatrooms")
}
var configuration: Path<Configuration> {
return append("configuration")
}
}

Now it’s starting to look like something! So we added another Phantom Type (the ChatRooms enum) — and a Configuration entity — just added here as an example.

The new constrained extension provides us with two sub-paths. One ‘pointing’ to our accounts and another to the configuration. On the call site these can be used like:

let chatRoomsPath = Path().chatRooms
let configurationPath = Path().configuration

The type of chatRoomsPath is now Path<ChatRooms> and configurationPath is Path<Configuration>.

You can imagine building an entire tree of paths like this, but we are still only getting started! :-)

Using the Path type

For now, the only thing we can do is create paths and render them.

This can of course be used in conjunction with a Reference in order to provide a String path. But we can do better.

At this point I would like to introduce an alterior motive with the wrapping of the Firebase RTDB APIs:

As we are building up more and more general concepts, we are also abstracting away from the actual Firebase APIs. This means that we are basically able to hide away the Firebase APIs and only use our own wrapper. This is extremely handy when it comes to testing: The tests do not need to know anything about Firebase — only our wrapped APIs. You can also imagine replacing the Firebase RTDB with something else entirely. For instance you could possibly transition to FireStore using the exact same wrapped API.

Let’s define a type to handle all Firebase RTDB observations — later on we can add a protocol defining the API for this for easy test mocking.

class FirebaseService {
private let rootRef: DatabaseReference
init(ref: DatabaseReference) {
// Ensure that rootRef is always pointing to the root
rootRef = ref.root
}
}

Now we can begin to take advantage of our Path type. Let us wrap the extensions to DatabaseReference and DatabaseQuery that we defined in the previous post — and spice it up using our Path type:

In the three functions we just call the extensions that we already provided in the previous post.

But more importantly we call them on a rendered Path and we connect the generic types from the Path to the generic parameter of the extension functions.

This is very powerful, because now we can only call setValue with Encodable types for paths that actually point to such a type.

This is probably easier to illustrate with an example:

let s = FirebaseService(...)
let config = Configuration(welcomeMessage: "Hello, World!")
try s.setValue(at: Path().chatRooms, value: config) // Fails
try s.setValue(at: Path().configuration, value: config) // Succeeds

In the first example, the compiler will let us know that ChatRooms is not Encodable, so it can’t be used. And the second example works as intended. Perfect!

Let us write out the full ‘chat rooms’ hierarchy as constrained extensions on Path to see more examples:

Now we can get a path to a Message:

let messagePath = Path().chatRooms.chatRoom("firechat").messages.message("first_post")

This is still a bit verbose, but we will enhance the ergonomics later on in this post.

Now that we have a path to another Encodable type let us try setting the Configuration entity on this path:

try s.setValue(at: messagePath, value: config) // Fails

This correctly fails — and the error message even gives a hint at what is wrong:

Cannot invoke ‘setValue’ with an argument list of type ‘(at: Path<Message>, value: Configuration)’

So the compiler directly tells you that you are providing a path to a Message but a value of type Configuration. This makes it easy to spot the error in the code.

As you may begin to see, this is indeed very powerful. The hierachy of Path types now defines a type safe ‘schema’ the tells the compiler where you may write different kinds of model objects in the database!

The observe function also improves greatly from being bound to the types of a path:

let path = Path().configuration
s.observeSingleEvent(of: .value, at: path) { result in
// result is now inferred to be of type:
// Result<Configuration, DecodeError>
}

This is another huge improvement to the API.

Again the Path hierarchy tells us what kind of data can reside at a specific path, so the compiler will try to decode that specific type — like the Configuration type above.

Again we will get an error if we try to call the function with a path to a type that is not Decodable.

Paths to collections of model entities

The example above highlights an issue with our API:

We still have to send the DataEventType to our API — and actually the specified event type contains some semantics about the use.

For instance the .value type returns data at a specific path while .childAdded returns data from child nodes of the path. This means that if we use a .childAdded type on a path to a Message, then we will not get called with actual message data, but rather when data below a message is added.

Wouldn’t it be awesome if we could model that some paths referred to collections of entities and others to the specific entity itself?

At Ka-ching we have tried a few ways of modelling this, but far the easiest way is to basically duplicate our Path type. We will name the copy Collection and nest it inside the Path type in order to avoid clashing with the standard library Collection type.

This means that a path to a collection of messages will be typed: Path<Message>.Collection.

That is quite a nice representation, don’t you think?

So the Path.Collection type differs from Path in that it has a child() function that given a String key always returns a Path to a specific instance of it’s own generic type.

The Path type is extended with a fileprivate version of append that can return paths to collections.

With this addition we can do a lot of new things. First of all our Path hierarchy becomes a lot smaller since we do not have to explicitly model the ChatRooms and Messages collections — they are now available ‘for free’:

Instead we have just specified that var chatrooms is a Path<ChatRoom>.Collection.

A path to a Message can now be expressed as:

let path = Path().chatrooms.child("firechat").messages.child("first_post")

You could even add helper extensions that lets you ‘jump’ directly into an entity in a collection and skip the .child() step:

extension Path where Element == ChatRoom {
var messages: Path<Message>.Collection {
return append("messages")
}
// Convenience function:
func message(_ key: String): Path<Message> {
return messages.child(key)
}
}

In this way the path can be built like this:

let path = Path().chatroom("firechat").message("first_post")

Simple and to the point!

Taking advantage of our collection paths

Now we can extend our FirebaseService in a number of ways. First let us get rid of the DataEventType parameter in our existing observer functions. It doesn’t make sense to call them with child added/changed/removed.

Instead we will also create new overloads for observing on collection paths where these event types still make sense.

Finally we will also add an addValue that may only be used on collections.

The changes are quite few:

We removed the DataEventTypefrom the Path observations and created new Path.Collection observations for use with only the .childAdded/.childChanged/.childRemovedevent types.

The addValue allows the adding entities to collections as follows:

let message = Message(...)
let path = Path().chatroom("firechat").messages
try s.addValue(at: path, value: message)

Conclusion

Adding the quite simple Path type and a small service class allows you to build a hierarchy which becomes a type safe definition of the structure of your database.

With this hierachy in place you make the compiler ensure that you can’t accidentally place data in locations that are not intended. And when observing, the type of data that is being parsed is inferred by the compiler. This reduces boilerplate and is yet another safe-guard with respect to parsing the data from the database into the correct type.

We are so thrilled to get rid of any stringly typed data in our code base, so thank you very much objc.io for the great inspiration for using Phantom Types! :-)

Future enhancements

It would be nice to have a function that could observe an entire collection of entities.

This can be done in a few different ways: We could just add another observe overload that returned [String: T] results using the .value event type. This will, however cause the entire collection to be re-downloaded and parsed every time a single entity changes in the database. Still, it could be useful when you know that you have small collections. The dangers of using this should be communicated in the API name.

Another way would be for the observe function to keep track of a [String: T] dictionary, observing both .childAdded, .childChanged and .childRemoved and then calling the handler with the new Dictionary each time a child is manipulated. This is a bit better than the above, but we are also moving towards some higher level logic that is further away from the existing Firebase APIs. We will have a look at this in the next blog post about wrapping all this up using RxSwift!

Another thing that becomes apparent when you are modelling your Path hierachy is how much this looks like an actual schema defined by some piece of data. Wouldn’t it be awesome if we could generate this boilerplate code based on a JSON representation of the Firebase RTDB hierarchy? I think we need to explore this in a fourth post in this three-part series. :-)

Who are we

My name is Morten Bek Ditlevsen, and I work for the Danish company Ka-ching (website in Danish).

At Ka-ching we are building a world class Point-of-Sales system for iOS. Currently we are B2B only, but we will keep you posted once we enter the App Store. :-)

--

--

Morten Bek Ditlevsen
Swift2Go

iOS dev since the unofficial tool chain. I love Swift and work at the awesome start-up: Ka-ching