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.