Swifty Firebase APIs @ Ka-ching — part 2
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 Reference
s.
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 String
s 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 DataEventType
from the Path
observations and created new Path.Collection
observations for use with only the .childAdded/.childChanged/.childRemoved
event 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. :-)