Skip to content

Instantly share code, notes, and snippets.

@mortenbekditlevsen
Created March 26, 2018 07:17
Show Gist options
  • Save mortenbekditlevsen/16a72e3d1ce2ece248bfb12246cb7f51 to your computer and use it in GitHub Desktop.
Save mortenbekditlevsen/16a72e3d1ce2ece248bfb12246cb7f51 to your computer and use it in GitHub Desktop.
Example of using Phantom generics to create a hierarchy reflecting a firebase rtdb tree structure
import Foundation
// Note: This code is inspired by the objc.io talk: https://talk.objc.io/episodes/S01E71-type-safe-file-paths-with-phantom-types
// Background: For a project, we were already using Firebase along with RxSwift.
// For this purpose we have a protocol, NetworkServiceType providing the API to the Firebase functionality. The reason for this is that we could potentially switch to a different technology in case we wanted to.
// In this playground I will just write dummy-implementations for NetworkServiceType, FirebaseService and for the RxSwift Observable since these are separate concerns.
// Dummy 'Observable'
enum Observable<T> {
case dummy
static func never<T>() -> Observable<T> { return .dummy }
}
protocol NetworkServiceType {
func observe<T>(pathString: String) -> Observable<T> where T: Decodable
}
struct FirebaseService: NetworkServiceType {
func observe<T>(pathString: String) -> Observable<T> where T : Decodable {
// FAKE DUMMY IMPLEMENTATION
return .never()
// Note: In the actual implementation FirebaseService carries a root 'ref' and
// does something like this:
// return ref.child(path).rx.observe(eventType: .value)
// The .rx extension on DatabaseQuery is where the actual decoding is performed, but again that's a whole other story.
}
}
// So, with this construct, we could do something like this:
struct Profile: Codable {
let name: String
}
let networkService: NetworkServiceType = FirebaseService()
// For some reason we have namespaced our firebase data to lie under a 'v1' node
let profile: Observable<Profile> = networkService.observe(pathString: "/v1/profiles/actual_profile_id_xyz")
// another example:
struct Configuration: Codable {
let importantGlobalConfigurationParameter: Bool
}
let config: Observable<Configuration> = networkService.observe(pathString: "/v1/global_config")
// Ok, so this is our starting point: We have this excellent RxSwift wrapper that ties so well together with Firebase and Codable
// Now we just want to get rid of these stringly typed paths.
// So from the Objc.io talk, we learn about a way of representing filesystem paths that can point to either files or directories.
// Internally, these are represented as an array of path elements. Let's do that too:
public struct Path<Element> {
private var components: [String]
private func append<T>(_ args: [String]) -> Path<T> {
return Path<T>(components + args)
}
private func append<T>(_ arg: String) -> Path<T> {
return append([arg])
}
private init(_ components: [String]) {
self.components = components
}
var rendered: String {
return components.joined(separator: "/")
}
}
// So the path is generic over the 'Element' which is where we use both Phantom types AND actual model types.
// For now we cannot construct a Path outside of the scope of this file.
// So let's add a Phantom type representing a path to the 'v1' root element:
public enum VersionRoot {}
extension Path where Element == VersionRoot {
public init() {
self.components = ["v1"]
}
}
// Now we have a Path that can be created:
let rootPath = Path()
// Note that by type inference, the compiler chooses the only valid initializer that returns a path to the VersionRoot
// So far, so good, but the fun only starts here.
// In the Objc.io talk there were only two path types: File and Directory.
// For our purpose we want to model our entire Firebase hierarchy as path types.
// For the 'leaves' in the hierarchy, we are going to use actual model types.
// One example of a leaf node is the 'Configuration' model above.
// We extend the 'VersionRoot' Path to be able to return a path to a Configuration:
extension Path where Element == VersionRoot {
var configuration: Path<Configuration> {
return append("global_config")
}
}
// Now we can construct a path to the model type: Configuration:
let configPath = Path().configuration
// And this would probably be a good point to create our small extension to the NetworkServiceType:
extension NetworkServiceType {
public func observe<T>(path: Path<T>) -> Observable<T> where T: Decodable {
return observe(pathString: path.rendered)
}
}
// Et voila! Let's see what we can do now:
let configObservable = networkService.observe(path: Path().configuration)
// configObservable is now inferred to have the type Observable<Configuration>! Yay! :-)
// The 'tree' of types can of course be extended as much as you wish.
// One common pattern in our database is 'Collections' of elements.
// For instance a 'Profile' above resides in the '/v1/profiles' node, so '/v1/profiles' can be thought of as containing a 'collection' of Profiles. Let's model that:
public struct CollectionPath<Entity> {}
// Now, we cannot extend the Path type to be constrained to a generic type, so we need to wrap it in a protocol.
// You need to ask smarter people to me as to why this can't be represented. I don't know if it's a limitation in Swift, or if there is some logical existential reason why this can't be done. But let's add a protocol so that we _can_ represent it in Swift:
public protocol CollectionPathProtocol {
associatedtype Element
}
// Our CollectionPath generic type can now be made to conform to this protocol:
extension CollectionPath: CollectionPathProtocol {
public typealias Element = Entity
}
// And finally we can model collection/child relationships on the Path
extension Path where Element: CollectionPathProtocol {
public func child(_ key: String) -> Path<Element.Element> {
return append([key])
}
}
// To see this in action, let's extend the VersionRoot path a bit further:
extension Path where Element == VersionRoot {
var profiles: Path<CollectionPath<Profile>> {
return append("profiles")
}
}
// Now we can do something like:
let profileObs = networkService.observe(path: Path().profiles.child("my_profile_id"))
// We can also use this nice CollectionPath construct to add functionality to the NetworkServiceType that only 'makes sense' for paths to collections of entities:
extension NetworkServiceType {
public func observe<T>(path: Path<CollectionPath<T>>) -> Observable<[String: T]> where T: Decodable {
// Actual implementation left out...
// return observe(path: path.rendered)
return .never()
}
}
// So now you can observe all profiles, keyed by their firebase key:
let allProfiles = networkService.observe(path: Path().profiles)
// Nice!!
// It would also make sense to implement a 'set' for a path to an Encodable entity
// And an 'add' to a path to a collection of Encodable entities:
extension NetworkServiceType {
public func set<T>(path: Path<T>, value: T) where T: Encodable {
// DUMMY
}
public func add<T>(path: Path<CollectionPath<T>>, value: T) where T: Encodable {
// DUMMY
}
}
// Which means that you can now write:
let morten = Profile(name: "Morten")
networkService.set(path: Path().profiles.child("id_of_mortens_profile"), value: morten)
// or add a new profile
let itua = Profile(name: "Itua")
networkService.add(path: Path().profiles, value: itua)
// Pretty neat! And the type system ensures that you can only 'add' to a collection of entities and only set on the path to the entity itself.
// To recap:
// Model the structure of the database as Path extensions where path elements that should not be directly referable are modelled as 'Phantom generics' that can never be instantiated, and where the leaf nodes are actual model entities or collections of model entities.
// Aaaand that's a wrap: * CLAP *
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment