Strongly typed identifiers in Swift

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.

11 thoughts on “Strongly typed identifiers in Swift”

  1. I personally like the first alternative and find it more readable.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

    protocol Identity {
      associatedtype Identifier
      var id: Identifier {get set}
    }
    
    struct Person: Identity {
      typealias Identifier = String
    
      var id: Identifier
      var name: String
      var age: Int?
    }
    
    func scrollToPerson(withId id: Person.Identifier) {
    }
    
    struct Building: Identity {
      typealias Identifier = Int
    
      var id: Identifier
      var name: String
      var age: Int?
    }
    
    func goToBuilding(id: Building.Identifier) {
    }
    
  6. 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:

    
    class Identifier: RawRepresentable, Hashable, Equatable {
        let rawValue: String
        init(rawValue: String) { self.rawValue = rawValue }
     }
    
    class PersonID: Identifier {}
    struct Person {
      let id: PersonID
      var name: String
      var age: Int?
    }
    
    class BuildingID: Identifier {}
    struct Building {
      let id: BuildingID
      var title: String
      var owner: Person
    }
    
  7. @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:

    
    let me = Person(id: "asdf", name: "asdf", age: 1)
    let home = Building(id: "asdf", name: "asdf", age: 10)
    goToBuilding(id: me.name)
    
  8. Personally the typealias approach strikes the best middle-ground. Another nice addition could be the following:

    
    extension GenericIdentifier : ExpressibleByStringLiteral {
        init(stringLiteral value: String) {
            self.init(rawValue: value)
        }
        init(unicodeScalarLiteral value: String) {
            self.init(stringLiteral: value)
        }
        init(extendedGraphemeClusterLiteral value: String) {
            self.init(stringLiteral: value)
        }
    }

    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 of let myPerson = Person(id:Person.Identifier(rawValue:"aaa"))

  9. This is my setup:

    
    struct IdentifierOf: RawRepresentable, Hashable, CustomStringConvertible, ExpressibleByStringLiteral, Codable {
        let rawValue: String
        var description: String { return rawValue }
        var urlEscaped: String { return rawValue.urlEscaped }
    
        init(rawValue: String) { self.rawValue = rawValue }
        init(_ value: String) { self.init(rawValue: value) }
        init(stringLiteral value: String) { self.init(rawValue: value) }
        init(unicodeScalarLiteral value: String) { self.init(rawValue: value) }
        init(extendedGraphemeClusterLiteral value: String) { self.init(rawValue: value) }
    }
    
    extension RawRepresentable where RawValue: Hashable {
        public var hashValue: Int { return rawValue.hashValue }
    }
    
    protocol Identifiable: Equatable {
        var id: IdentifierOf { get }
    }
    
    extension Identifiable {
        public static func ==(lhs: Self, rhs: Self) -> Bool {
            return lhs.id == rhs.id
        }
    }
    

    Been using this approach for several months now and it works well for me.
    Implementing a model object looks like that:

    struct User: Identifiable, Codable {
        typealias Identifier = IdentifierOf<User>
        let id: Identifier
        let name: String
        let title: String?
    }
    

    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")))

Leave a Reply

Your email address will not be published. Required fields are marked *