Erase your fear, type erasure is here

OkCupid is hiring for iOS Click here to learn more

Introduction

Here at OkCupid we are constantly striving to implement best in class Swift practices. One of these concepts is type erasure. You might be thinking to yourself "I have never had to use type erasure in my life..." but you're wrong! Little did you know, AnyObject is actually a protocol that erases the concrete type of any object. You may have even seen the Any prefix scattered around the Swift Standard Library (AnyHashable, AnyIterator, AnyCollection, etc).

This powerful construct is a double-edged sword. On one hand, since all classes conform to AnyObject, it acts as a universal wrapper for all types which can be useful if you need to work with unknown types. On the other hand, it removes the reliance on Swift's Type Safety system. They don't call it "safety" for no reason! There are important benefits to knowing the type of an object you are working with and we are all too familiar with the ole' Objective-C crash from "Unrecognized selector sent to instance" when you call a method unimplemented on a type.

But could there be a best of both worlds solution? Here at OkCupid we think the answer is a resounding YES. We can take the power of type erasure and apply it to a specific use case to get the benefits of flexible collections and still maintain the rigidity of Swift's robust Type Safety system.

For the sake of illustration, we have put together a sample project that includes code from our app implementing a specialized form of Type Erasure for our Conversations view controller.

https://github.com/OkCupid/swift-type-erasure

iVBORw0KGgoAAAANSUhEUgAABaMAAAszCAYAAACIWkB7AAABgmlDQ1BzUkdCIElFQzYxOTY2LTIu-5

The Problem

For this example, we are going to be building the model that represents a conversation in the OkCupid iOS app.

When building out the model layer for our conversations screen, we had a few goals in mind:

  1. We needed good abstraction to support persistence for the feature in the future
  2. The model needed to be easily mockable for unit testing
  3. The architecture should be extensible, to allow us to expand the logic as needed

Creating a simple struct was our first approach:

struct Conversation {
    let threadId: String
    let isUnread: Bool
    let correspondent: User
    // ..  and so on
}

However, we quickly realized that this approach did not meet all of our goals. Specifically, this did not give us the extensibility we needed. For example, if OkCupid were to introduce a new conversation type we would need to refactor a lot of code to support it.

Some examples of these "future" types might be PersistingConversation, GroupConversation, SecretConversation, etc. Because Conversation is a concrete struct, supporting multiple types of conversations throughout the app would need a significant refactor.

Since we know that any type of conversation will share the same set of basic properties, let's abstract the concrete type using a protocol.

The Protocol Oriented Approach

Let's create ConversationProtocol, a protocol which contains all the shared properties of a conversation.

protocol ConversationProtocol {
    var threadId: String { get }
    var correspondent: UserProtocol { get }
    var isUnread: Bool { get }
    // etc..
}

ConversationProtocol protocol is perfect for expressing shared property requirements, but we also want some shared functionally amongst our conversations. For example, we need a way to differentiate one conversation from another. Luckily, Swift provides us with the Hashable protocol (which inherits from the Equatable protocol) for this purpose.

What we want is a single implementation of Hashable that can be shared amongst the current and future concrete implementations of ConversationProtocol. However, it's impossible for a protocol (ConversationProtocol) to implement another protocol (Hashable). We need a concrete type

We can instead provide a default implementation of a protocol on another protocol by using the where declaration in the extension, which specifically applies to concrete types:

extension ConversationProtocol where Self: Hashable {

    func hash(into hasher: inout Hasher) {
        hasher.combine(threadId)
    }

    func isEqualTo(_ other: ConversationProtocol) -> Bool {
        guard let otherConversation = other as? Self else {
            return false
        }

        return threadId == otherConversation.threadId
    }

    static func ==(lhs: Self, rhs: Self) -> Bool {
        return lhs.isEqualTo(rhs)
    }
}

This tells the compiler that any concrete type that implements ConversationProtocol and Hashable gets a default implementation for free! This is tremendously powerful because it allows for a single central implementation of Hashable among all concrete types of ConversationProtocol.

To use it, all we need to do is create a concrete type:

struct Conversation: ConversationProtocol {
    var threadId: String
    var correspondent: UserProtocol
    var isUnread: Bool
}

Now, to use our shared implementation of Hashable all we need to do is:

// This is where the magic happens 🎩✨
extension Conversation: Hashable {}

Now we have a concrete Conversation struct that automatically gets our implementation of Hashable simply by providing a blank implementation.

BUT... This is all great until we try to store our instance of ConversationProtocol in set... after all, we still want to use the abstracted type for ultimate flexibilty.

var conversationSet = Set<ConversationProtocol>()

The compiler is yelling at us...

error: type 'ConversationProtocol' does not conform to protocol 'Hashable'

This makes sense as a protocol cannot implement a protocol. So how do make a set of ConversationProtocols? This is where type erasure comes into play.

We need a concrete type that conforms to Hashable to store in a set.

struct AnyHashableConversation: ConversationProtocol {

    var threadId: String {
        return conversation.threadId
    }

    var correspondent: UserProtocol {
        return conversation.correspondent
    }

    var isUnread: Bool {
        return conversation.isUnread
    }

    private let conversation: ConversationProtocol

    init(conversation: ConversationProtocol) {
        self.conversation = conversation
    }
}   

// Use our existing Hashable implementation
extension AnyHashableConversation: Hashable {}

That's it! Now we can create a set of any conversation type and it will be hashed using a single shared implementation of Hashable!

let conversations = Set<AnyHashableConversation>()

Conclusion

The use of protocols combined with type erasure is a useful technique for abstracting common functionality amongst many concrete types. It allows us to keep our code loosley coupled, extensible, and easily testable. Do you have experience using type erasure or other abstractions to solve similar problems? Leave us your thoughts, we'd love to learn more!

Header image courtesy of: https://www.iteratorshq.com/blog/scala-compiler-phases-with-pictures/type-erasure/