In a lot of our code, we have structs that contain some sort of identifier. This is usually a serverside generated id, that uniquely identifies some record in a database.
struct Person {
let id: String
var name: String
var age: Int?
}
struct Building {
let id: String
var title: String
var owner: Person
}
Strings as identifiers
Sometimes these identifiers are UUIDs or Ints, but mostly they are Strings. Opaque to the client, but meaningful on the server.
Most functions in our codebase work on structs directly. But some use the identifiers, since they exist anyway:
func scrollToPerson(_ person: Person) {
}
func scrollToPerson(withId id: String) {
}
The scrollToPerson(_:)
function is strongly typed, but the scrollToPerson(withId:)
function isn’t.
This leads to accidentally mistakes, where we use the wrong identifier:
scrollToPerson(withId: mainBuilding.id) // Wrong
scrollToPerson(withId: mainBuilding.owner.id) // Correct
Strongly typed identifiers
We’ve recently begon creating strongly typed structs for these identifiers:
struct Person {
struct Identifier: RawRepresentable, Hashable, Equatable {
let rawValue: String
init(rawValue: String) { self.rawValue = rawValue }
}
let id: Identifier
var name: String
var age: Int?
}
struct Building {
struct Identifier: RawRepresentable, Hashable, Equatable {
let rawValue: String
init(rawValue: String) { self.rawValue = rawValue }
}
let id: Identifier
var title: String
var owner: Person
}
With this strongly typed Identifier in place, we no longer can accidentally use the wrong identifier.
func scrollToPerson(withId id: Person.Identifier) {
}
// This causes a type error:
scrollToPerson(withId: mainBuilding.id)
^
// Cannot convert value of type 'Building.Identifier' to expected argument type 'Person.Identifier'
We implement the Hashable
and Equatable
protocols so we can use these identifiers in Sets and as Dictionary keys.
We use RawRepresentable
so we get the Equatable implementation for free, Hashable is implemented in a protocol extension:
extension RawRepresentable where RawValue : Hashable {
public var hashValue: Int { return rawValue.hashValue }
}
Wishlist: newtype
Haskell has a language feature that implements this pattern of wrapping an existing type to create a new type. It is called: newtype
.
That would also be nice to have in Swift. It looks similar to typealias
, but creates a new type, instead of just an alias. Then we could write:
struct Person {
newtype Identifier = String
let id: Identifier
var name: String
var age: Int?
}
Alternative: phantom types
As an alternative to creating multiple separate identifier types, we could create a single generic type, using a phantom type:
struct Identifier<T>: RawRepresentable, Hashable, Equatable {
let rawValue: String
init(rawValue: String) { self.rawValue = rawValue }
}
The generic type argument T
here isn’t used in the Identifier type itself, it is only used to distinguish different identifiers.
When using this generic identifier, the previous example becomes:
struct Person {
let id: Identifier<Person>
var name: String
var age: Int?
}
func scrollToPerson(withId id: Identifier<Person>) {
}
This does have the benefit of being shorter, and thus easier to write. But is it better?
I don’t think this code is easier to read than the previous version with Person.Identifier
. In fact, I think it’s harder to read. This is a classic case of optimising for writing code, instead of optimising for reading the code.
To keep code maintainable in the long run, I thing we should strive for readability over writability. So I’ll stick with writing multiple separate types, and waiting for a newtype
construct.
Alternative 2: A typealias
(Addition 2017-07-13)
A colleague suggested a second alternative; Using a typealias to keep te readability of scrollToPerson(withId id: Person.Identifier)
, but also using the generic single definition.
struct GenericIdentifier<T>: RawRepresentable, Hashable, Equatable {
let rawValue: String
init(rawValue: String) { self.rawValue = rawValue }
}
struct Person {
typealias Identifier = GenericIdentifier<Person>
let id: Identifier
var name: String
var age: Int?
}
func scrollToPerson(withId id: Person.Identifier) {
}
Maybe this is a nice middle ground. Although I would throw this GenericIdentifier<T>
away, once Swift gains a newtype
construct.
I personally like the first alternative and find it more readable.
Ahoy,
A third alternative: Alternative number 2, but under steroids by using protocol class and associatedtypes/typealias.
see this gist:
https://gist.github.com/juliengdt/fd5bbeccb5246cab1d615b686e9470d5
Cheers From France
Julien – iOS Swift Lead Developer
I would pick alternative 2. Julien’s alternative is a bit less readable imho. Also it opens the door for parameters of type Identifiable, which then would loose the strongly typed purpose again.
I prefer the first alternative too. Yes, readability is a large factor but type safety and reusability could more useful for maintainability. I also find the generic version easy to read anyway. Then it could also be reused as a generic
scrollTo<T>(id: Identifier<T>)
method in different contexts because if you’re needing to scroll to an object in one list of objects you’ll likely need to do it somewhere else for a different list of different objects.To elaborate on my opposition for the first alternative, with the generic phantom type:
The main code (
scrollToPerson(withId:)
) isn’t generic at all. It doesn’t abstract over different types, nor is it reusable for arbitrary scrolling to different things. It is a very specific function, meant for a specific purpose. Adding the complexity of generics doesn’t add any value, it only increases the mental overhead when reading the function.In fact, nowhere in my entire codebase do I ever need to abstract over different types of identifiers. The whole point is that they are distinct types, with no relation to each other, so that they can’t be mixed up.
A more generic approach that will work with any type of id. Off course concrete class have to will have to define the type of alias, but that is ok.
newType would be a great addition to swift. The functionality is exists for optionals but is not exposed to be used for other types.
For now I’ve defined Identifier as a class and use subclassing to create different ids:
@tarun
I don’t think that approach would work because I can still do this with that setup if I change the Buildings Identifier type to String:
Personally the typealias approach strikes the best middle-ground. Another nice addition could be the following:
This allows you to do assignment & comparison against string literals without having to use the unwieldy
Person.Identifier(rawValue:"aaa")
constructor…So
let myPerson = Person(id:"aaa")
instead oflet myPerson = Person(id:Person.Identifier(rawValue:"aaa"))
This is my setup:
Been using this approach for several months now and it works well for me.
Implementing a model object looks like that:
and making an API call with Moya looks like that:
API.shared.request(.user(userID: user.id))
If I have to pass in a string:
API.shared.request(.user(userID: User.Identifier("12345")))