Type-safe identifiers in Swift

In most code bases, we need a way to uniquely identify certain values and objects. It can be when keeping track of models in a cache or database, or when performing a network request to fetch more data for a certain entity.

In "Identifying objects in Swift" we took a look at how objects can be identified using the built-in ObjectIdentifier type - and this week, we're going to construct similar identifier types for values, with an increasing degree of type safety.

Let's dive right in! πŸ˜€

A dedicated type

As a language, Swift puts a heavy focus on types and type safety. With features like generics, protocols and static typing, we're always encouraged (or forced even) to know the exact types of the objects and values that we're dealing with.

When it comes to identifiers, the Swift standard library provides the dedicated UUID type - which enables us to easily add a unique identifier to any value type, like a User model:

struct User {
    let id: UUID
    let name: String
}

However, UUID is purpose-built to be backed by a string with a given standardized format (RFC 4122 to be specific), which is not always practical (or even possible) when identifiers need to be used across multiple different systems (such as the app's server, or other platforms, like Android).

Because of that, it's very common to instead just use plain strings as identifiers, like this:

struct User {
    let id: String
    let name: String
}

While strings are nice for things like text, they're not a very robust or safe solution for something like identifiers. Not every string is a valid identifier, and ideally we'd like to leverage the type system to prevent bugs that can occur when accidentally passing an incompatible string as an identifier.

Strings can also do many things that we don't really want our identifiers to be able to do (like having characters added or removed). Ideally, we'd like a more narrow, dedicated type (just like UUID) that we can use to model any identifier.

The good news is that defining such a type is very easy, and since we now (since Swift 4.1) get Hashable support for free, all we really have to do is declare an Identifier type that is backed by a string of any format - like this:

struct Identifier: Hashable {
    let string: String
}

We can now use our dedicated Identifier type in our model code, to make it crystal clear that a certain property is an identifier, not just any string:

struct User {
    let id: Identifier
    let name: String
}

The native feel

Our new Identifier type is pretty nice, but it doesn't feel as "native" to use as when we were using plains strings. For example, initializing an identifier now requires us to pass the underlying string as a parameter, like this:

let id = Identifier(string: "new-user")

Thankfully, this is something we can easily fix. Since the Swift standard library is so protocol oriented, making our own custom type feel right "at home" just requires us to conform to a few simple protocols.

First up, let's make it possible to create an identifier using a string literal. That way, we get the same convenience as when using plain strings, but with the added safety of using a dedicated type. To do that, all we have to do is conform to ExpressibleByStringLiteral and implement an additional initializer:

extension Identifier: ExpressibleByStringLiteral {
    init(stringLiteral value: String) {
        string = value
    }
}

We'd also like to make it simple to print an identifier, or include one in a string literal. To make that happen, we'll add a conformance to CustomStringConvertible as well:

extension Identifier: CustomStringConvertible {
    var description: String {
        return string
    }
}

With the above in place, we can now both easily bridge our dedicated Identifier type to and from string literals:

let user = User(id: "new-user", name: "John")
print(user.identifier) // "new-user"

Another feature that would make our identifier type feel more native is to add coding support. With the introduction of Codable, we could simply have the compiler generate coding support for us, but that would require our data (for example JSON) to have the following format:

{
    "id": {
        "string": "49-12-90-21"
    },
    "name": "John"
}

That's not very nice, and it will again make compatibility with other systems and platforms a lot harder. Instead, let's write our own Codable implementation, using a single value container:

extension Identifier: Codable {
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        string = try container.decode(String.self)
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(string)
    }
}

With the above in place, we can now also encode and decode an Identifier value using a single string, just like how we're able to initialize one using a string literal. Our dedicated type now has a very nice native feel to it, and can naturally be used in many different contexts. Pretty sweet! 🍭

Even more type safety

We've now prevented strings from being confused with identifiers, but we can still accidentally use an identifier value with an incompatible model type. For example, the compiler won't give us any kind of warning if we accidentally end up with code like this:

// Ideally, it shouldn't be possible to look up an 'Article' model
// using a 'User' identifier.
articleManager.article(withID: user.id)

So even though having a dedicated Identifier type is a huge step towards better type safety, we can still take things further by associating a given identifier with the type of value that it's representing. To do that, let's add a generic Value type to Identifier:

struct Identifier<Value>: Hashable {
    let string: String
}

We're now forced to specify what kind of identifier we're dealing with, making each identifier strongly associated only with its corresponding value, like this:

struct User {
    let id: Identifier<User>
    let name: String
}

If we now again try to look up an Article using a User identifier, we'll get a compiler error saying that we're trying to pass a value of Identifier<User> to a function that accepts a parameter of type Identifier<Article>.

Generalizing our generic

Can we take it a step further? Totally! πŸ˜‰

Not all identifiers are backed by strings, and in some situations we might need our identifier type to also support other backing values - like Int, or even UUID.

We could of course make that happen by introducing separate types (like StringIdentifier and IntIdentifier), but that'd require us to duplicate a bunch of code, and wouldn't really feel as "Swifty". Instead, let's introduce a protocol that our identifiable models and values can conform to:

protocol Identifiable {
    associatedtype RawIdentifier: Codable = String

    var id: Identifier<Self> { get }
}

As you can see above, our Identifiable protocol allow our various types the flexibility to declare a custom RawIdentifier type - while still keeping the convenience of having String as the default. We'll then require the generic Value type of Identifier to conform to Identifiable, and we're done:

struct Identifier<Value: Identifiable> {
    let rawValue: Value.RawIdentifier

    init(rawValue: Value.RawIdentifier) {
        self.rawValue = rawValue
    }
}

Since we chose to leave String as the default raw value, all of our existing models will work just as before, all we need to do is add a conformance to Identifiable:

struct User: Identifiable {
    let id: Identifier<User>
    let name: String
}

Thanks to Swift 4.1's Conditional conformances feature, we can now easily support both Int and String literals as well. All we have to do is add a constraint on the value's RawIdentifier when conforming to each literal expression protocol, like this:

extension Identifier: ExpressibleByIntegerLiteral
          where Value.RawIdentifier == Int {
    typealias IntegerLiteralType = Int

    init(integerLiteral value: Int) {
        rawValue = value
    }
}

With the above in place, we are now free to use Int-based identifiers as well, using the exact same code, in a very type-safe way:

struct Group: Identifiable {
    typealias RawIdentifier = Int

    let id: Identifier<Group>
    let name: String
}

Type safety everywhere

Having type-safe identifiers is not only useful when it comes to preventing developer mistakes, it also enables us to construct some really nice APIs when it comes to handling our models and values in different contexts. For example, now that we have the Identifiable protocol, we could make our database code type-safe as well:

protocol Database {
    func record<T: Identifiable>(withID id: Identifier<T>) -> T?
}

The same thing goes for our networking code, which can also be made generic with a constraint on Identifiable:

protocol ModelLoader {
    typealias Handler<T> = (Result<T>) -> Void

    func loadModel<T: Identifiable>(withID id: Identifier<T>,
                                    then handler: @escaping Handler<T>)
}

We can now leverage the type system and the compiler in many more ways throughout our code base πŸŽ‰.

Conclusion

Using type-safe identifiers can be a great way to make our model code more robust and less prone to errors caused by accidentally using the wrong kind of identifier. It also enables us to leverage the compiler in interesting ways throughout our code base, in order to make handling models and values a lot simpler and more safe.

What degree of type safety that will be appropriate for your code will of course vary a lot depending on your exact setup and requirements. Simply using a dedicated, non-generic Identifier type might be enough, and can both be easy to implement and be a big step towards more type-safe identifier handling. If you want or need even more type safety, using generics and protocols can also be a great option when taking things further.

What do you think? What kind of identifiers do you use, and what kind of type safety do you like your identifiers to have? Let me know - along with any questions, comments or feedback you might have - on Twitter @johnsundell.

Thanks for reading! πŸš€

Launch arguments in Swift

Logic controllers in Swift