Recently I had to build a system to onboard users. This meant collecting different pieces of information over a number of screens. We are also utilizing A/B testing which means not only can the order of screens change, but certain pieces of information may be collected by a single screen or broken up across many.
The data I want to end up with looks something like:
struct User {
let firstName: String
let lastName: String
let age: Int?
}
There are multiple ways to go about collecting this data, I could simply give each screen an optional property:
class FirstNameViewController: UIViewController {
private var firstName: String?
// ...
}
This would get the job done, but there are a few problems to overcome...
An alternate approach would be to have a second version of our model with optional properties, such as:
struct PartialUser {
let firstName: String?
let lastName: String?
let age: Int?
}
This is an improvement, we now have all the pieces in one place. We can create a User.init
that accepts this model to produce a complete User
instance for us:
extension User {
init(from partial: PartialUser) {
// ...
}
}
However it comes with it own set of problems...
init
can look a little messy having to deal with both required and optional propertiesIt's worth noting that neither of these solution scale well for other uses, there is a lot of associated boilerplate that we would need to replicate for each specific use case.
We could try to solve the scaling problem with a Dictionary
... what about using [String: Any]
? While this scales fine it's a step backwards in safety.
String
keys are problematic, they are prone to typos and will easily fall out of sync. We could look at using a String
enum but then we've re-introduced our scaling issue again!
On the value side Any
strips all our type information and we would then have to cast values back top the desired types again anyway.
What we need is something that combines the last two attempts. It should scale like a dictionary but gives us the type safety of an explicit model.
Lets stick with the Dictionary
for now. Can we improve on String
keys? Turns out Swift KeyPath
s are a great solution to this!
var partialUser: [PartialKeyPath<User>: Any] = [:]
By using a PartialKeyPath
we are able to restrict the keys to only properties on User
like so:
partialUser[\User.firstName] = "Ian"
This is great! Now if our User
model changes this dictionary will scale perfectly with it. New properties will be available as they are added and changes to existing properties will cause the compiler to complain.
What about the pesky Any
? Right now you could replace the String
value "Ian"
with something like an Int
of 42
and it would still compile (though it will fail when you try and extract it). Is there a different type we can use here to fix that?
Sadly no...
But there is hope! Let's build a new type that will solve this problem and make this solution more generic (pun intended 😄)
Let's start by putting in the KeyPath
based Dictionary
we have already to keep track of our changes:
struct Partial<T> {
private var data: [PartialKeyPath<T>: Any] = [:]
//...
}
This gives us a generic type that we can now use with any type we want:
var partial = Partial<User>()
Next, we can use a generic function to ensure the dictionary is updated with the correct types:
mutating func update<U>(_ keyPath: KeyPath<T, U>, to newValue: U?) {
data[keyPath] = newValue
}
We use a full KeyPath
here so we can gain access to the generic type of the value of the property. This works because KeyPath
is a subclass of PartialKeyPath
. With this function we can now update the data using:
partial.update(\.firstName, to: "Ian")
And because we now have access to the properties type we can restrict the value being set. For instance we can no longer pass 42
. It's also worth noting that we can pass nil
to erase any stored value too! We now have a type safe, scalable, setter!
We can use these same features to also build out the getter:
func value<U>(for keyPath: KeyPath<T, U>) throws -> U {
guard let value = data[keyPath] as? U else { throw Error.valueNotFound }
return value
}
Here we are encapsulating the casting of Any
to the desired type and adding error handling. We also add in an overload to allow us to deal with optionals in a consistent way.
I should point out that there is one potential gotcha with the current implementation... when you use update(_:to:)
you are only associating a single KeyPath
with a single value. What this means is that if you are working with data like:
struct Pet {
let name: String
}
struct User {
let name: String
let pet: Pet
}
And you update the value like so:
var partial = Partial<User>()
partial.update(\.pet, to: Pet(name: "Rover"))
This only creates a pairing of the pet
KeyPath
and the Pet
object, you cannot then extract the nested data using:
let petName = try partial.value(for: \.pet.name)
This will fail because the inner Dictionary
does not have an entry for \.pet.name
... only \.pet
. You need to ensure you are first extracting the data using a KeyPath
you have already used then accessing the data from that:
let pet = try partial.value(for: \.pet)
let petName = pet.name
To correct this we can add an overload for value(for:)
that first extracts the stored property then allows us you use KeyPath
s to dig further down:
func value<U, V>(for keyPath: KeyPath<T, U>, _ inner: KeyPath<U, V>) throws -> V {
let root = try value(for: keyPath)
return root[keyPath: inner]
}
Using this you could then do
let petName: String = try partial.value(for: \.pet, \.name)
This is great because once you have the 'root' object the inner KeyPath
can dig down any number of nested levels.
This is what our full Partial
looks like. I've also added some overloads to better handle optionals too:
struct Partial<T> {
enum Error: Swift.Error {
case valueNotFound
}
private var data: [PartialKeyPath<T>: Any] = [:]
mutating func update<U>(_ keyPath: KeyPath<T, U>, to newValue: U?) {
data[keyPath] = newValue
}
mutating func update<U>(_ keyPath: KeyPath<T, U?>, to newValue: U?) {
data[keyPath] = newValue
}
func value<U>(for keyPath: KeyPath<T, U>) throws -> U {
guard let value = data[keyPath] as? U else { throw Error.valueNotFound }
return value
}
func value<U>(for keyPath: KeyPath<T, U?>) -> U? {
return data[keyPath] as? U
}
func value<U, V>(for keyPath: KeyPath<T, U>, _ inner: KeyPath<U, V>) throws -> V {
let root = try value(for: keyPath)
return root[keyPath: inner]
}
func value<U, V>(for keyPath: KeyPath<T, U?>, _ inner: KeyPath<U, V>) -> V? {
guard let root = value(for: keyPath) else { return nil }
return root[keyPath: inner]
}
}
And we can now extend our original User
model like so:
extension User {
init(from partial: Partial<User>) throws {
self.firstName = try partial.value(for: \.firstName)
self.lastName = try partial.value(for: \.lastName)
self.age = partial.value(for: \.age)
}
}
Sadly we are not able to provide a default implementation for the convenience init
yet. I've explored a few ways of getting this to work however the core issue is that there is, currently, no way of converting to or from KeyPath
s to other types.
It's a shame but regardless, I think this is an interesting use of KeyPath
s. I also like the feel of this solution when compared to the other attempts because of the ability to exactly mirror the underlying model and the resulting compiler safety.
UPDATE: I forgot to mention that while there is no way to provide a default implementation for the convenience init
you can of course use a tool like Sourcery to do this for you until KeyPath
s get some love.
Let me know what you think!