Different flavors of type erasure in Swift

Swift’s overarching goal of both being powerful enough to be used for low-level systems programming, as well as easy enough for beginners to learn, can sometimes lead to quite interesting situations — when the power of Swift’s type system requires us to deploy quite advanced techniques to solve problems that, at first glance, might’ve seemed much more trivial.

One such situation that most Swift developers will encounter at one point or another (often sooner, rather than later) — is when some form of type erasure is needed to be able to reference a generic protocol. This week, let’s start by taking a look at what makes type erasure such an essential technique in Swift, and then move on to explore different “flavors” of implementing it — and how each flavor comes with its own set of pros and cons.

When is type erasure needed?

The words “type erasure” might at first seem counter-intuitive to Swift’s heavy focus on types and compile-time type safety — so it might be better described as hiding types rather than completely erasing them. The goal is to enable us to more easily interact with generic protocols that have requirements that are specific to the various types that’ll implement them.

Take the Equatable protocol from the standard library as an example. Since it’s all about enabling two values of the same type to be compared in terms of equality, it uses the Self metatype for the arguments of its only method requirement:

protocol Equatable {
    static func ==(lhs: Self, rhs: Self) -> Bool
}

The above makes it possible for any type to conform to Equatable, while still requiring the values on both sides of the == operator to be of the same type, since each type conforming to the protocol will have to ”fill in” its own type when implementing the above method:

extension User: Equatable {
    static func ==(lhs: User, rhs: User) -> Bool {
        return lhs.id == rhs.id
    }
}

What’s great about that approach is that it makes it impossible to accidentally compare two unrelated equatable types (such as User and String), however — it also makes it impossible to reference Equatable as a stand-alone protocol (such as creating an array like [Equatable]), since the compiler needs to know what exact type is actually conforming to the protocol in order to be able to use it.

The same is also true when a protocol contains associated types. For example, here we’ve defined a Request protocol to enable us to hide various forms of data requests (like network calls, database queries, and cache fetches) behind a single, unified implementation:

protocol Request {
    associatedtype Response
    associatedtype Error: Swift.Error

    typealias Handler = (Result<Response, Error>) -> Void

    func perform(then handler: @escaping Handler)
}

The above approach gives us the same set of trade-offs that Equatable has — it’s really powerful because it’ll enable us to create common abstractions for any kind of request, but it also makes it impossible to directly reference the Request protocol itself, like this:

class RequestQueue {
    // Error: protocol 'Request' can only be used as a generic
    // constraint because it has Self or associated type requirements
    func add(_ request: Request,
             handler: @escaping Request.Handler) {
        ...
    }
}

One way to solve the above problem is to do exactly what the error message says, and not reference Request directly, but instead use it as a generic constraint:

class RequestQueue {
    func add<R: Request>(_ request: R,
                         handler: @escaping R.Handler) {
        ...
    }
}

The above works, since now the compiler is able to guarantee that the passed handler is indeed compatible with the Request implementation passed as request — because they’re both based on the generic type R, which in turn is constrained to conform to Request.

However, while we’ve solved our method signature problem, we still won’t be able to actually do much with the passed request — since we won’t be able to store it, either as a Request property or as part of a [Request] array, which’ll make it difficult to continue building our RequestQueue. That is, until we start doing type erasure.

Generic wrapper types

The first flavor of type erasure that we’ll explore doesn’t actually involve erasing any types, but rather wrapping them in a generic type that we can more easily reference. Continuing to build on the RequestQueue example from before, we’ll start by creating that wrapper type — which’ll capture each request’s perform method as a closure, together with the handler that should be called once the request has been completed:

// This will let us wrap a Request protocol implementation in a
// generic has the same Response and Error types as the protocol.
struct AnyRequest<Response, Error: Swift.Error> {
    typealias Handler = (Result<Response, Error>) -> Void

    let perform: (@escaping Handler) -> Void
    let handler: Handler
}

Next, we’ll also turn the RequestQueue itself into a generic over the same Response and Error types — making it possible for the compiler to guarantee that all the associated and generic types line up, allowing us to store requests, both as stand-alone references and as part of an array — like this:

class RequestQueue<Response, Error: Swift.Error> {
    private typealias TypeErasedRequest = AnyRequest<Response, Error>

    private var queue = [TypeErasedRequest]()
    private var ongoing: TypeErasedRequest?

    // We modify our 'add' method to include a 'where' clause that
    // gives us a guarantee that the passed request's associated
    // types match our queue's generic types.
    func add<R: Request>(
        _ request: R,
        handler: @escaping R.Handler
    ) where R.Response == Response, R.Error == Error {
        // To perform our type erasure, we simply create an instance
        // of 'AnyRequest' and pass it the underlying request's
        // 'perform' method as a closure, along with the handler.
        let typeErased = AnyRequest(
            perform: request.perform,
            handler: handler
        )

        // Since we're implementing a queue, we don't want to perform
        // two requests at once, but rather save the request for
        // later in case there's already an ongoing one.
        guard ongoing == nil else {
            queue.append(typeErased)
            return
        }

        perform(typeErased)
    }

    private func perform(_ request: TypeErasedRequest) {
        ongoing = request

        request.perform { [weak self] result in
            request.handler(result)
            self?.ongoing = nil

            // Perform the next request if the queue isn't empty
            ...
        }
    }
}

Note that the above example, as well as the other pieces of sample code in this article, is not thread-safe — in order to keep things simple. For more info on thread safety, check out “Avoiding race conditions in Swift”.

The above works well, but has a few downsides. Not only did we introduce a new AnyRequest type, we also needed to turn RequestQueue into a generic. That gives us a bit less flexibility, since we can now only use any given queue for requests that have the same response/error type combination. Ironically, it might require us to also type erase the queue itself in the future — in case we ever want to compose multiple instances.

Closures to the rescue!

Instead of introducing wrapper types, let’s take a look at how we could use closures to achieve the same kind of type erasure — while also keeping our RequestQueue non-generic, and versatile enough to be used with different kinds of requests.

When type erasing using closures, the idea is to capture all of the type information needed to perform an operation inside of a closure, and have that closure accept only non-generic (or even Void) input. That’ll let us reference, store, and pass around that piece of functionality without actually knowing what happens inside of it — giving us an even greater degree of flexibility.

Here’s how RequestQueue could be updated to instead use closure-based type erasure:

class RequestQueue {
    private var queue = [() -> Void]()
    private var isPerformingRequest = false

    func add<R: Request>(_ request: R,
                         handler: @escaping R.Handler) {
        // This closure will capture both the request and its
        // handler, without exposing any of that type information
        // outside of it, providing full type erasure.
        let typeErased = {
            request.perform { [weak self] result in
                handler(result)
                self?.isPerformingRequest = false
                self?.performNextIfNeeded()
            }
        }

        queue.append(typeErased)
        performNextIfNeeded()
    }

    private func performNextIfNeeded() {
        guard !isPerformingRequest && !queue.isEmpty else {
            return
        }

        isPerformingRequest = true
        let closure = queue.removeFirst()
        closure()
    }
}

While relying too heavily on closures to capture both functionality and state can sometimes make our code harder to debug, it can also make it possible to fully encapsulate type information — making it possible for objects like our RequestQueue to do its work without really knowing any specifics about the types at work under the hood.

For more information on closure-based type erasure and some different takes on it — check out Type erasure using closures in Swift.

External specialization

So far we’ve been performing all of our type erasure within RequestQueue itself, which has some advantages — it makes it possible to use our queue without making any outside code aware that any kind of type erasure is happening. However, sometimes doing some lightweight conversion before passing a protocol implementation to an API can both let us keep things simpler, while also neatly encapsulating the type erasure code itself.

One way to do that for our RequestQueue is to require each Request implementation to be specialized before it’s added to a queue — which will convert it into a RequestOperation, that looks like this:

struct RequestOperation {
    fileprivate let closure: (@escaping () -> Void) -> Void

    func perform(then handler: @escaping () -> Void) {
        closure(handler)
    }
}

Similar to how we previously used closures to perform type erasure within RequestQueue, the above RequestOperation type will enable us to do that within an extension on Request instead:

extension Request {
    func makeOperation(with handler: @escaping Handler) -> RequestOperation {
        return RequestOperation { finisher in
            // We actually want to capture 'self' here, since otherwise
            // we risk not retaining the underlying request anywhere.
            self.perform { result in
                handler(result)
                finisher()
            }
        }
    }
}

The beauty of the above reproach is that it lets us keep our RequestQueue much simpler, both in terms of its public API and its internal implementation. It can now be fully focused on being a queue, instead of having to concern itself with any kind of type erasure:

class RequestQueue {
    private var queue = [RequestOperation]()
    private var ongoing: RequestOperation?

    // Since the type erasure now happens before a request is
    // passed to the queue, it can simply accept a concrete
    // instance of 'RequestOperation'.
    func add(_ operation: RequestOperation) {
        guard ongoing == nil else {
            queue.append(operation)
            return
        }

        perform(operation)
    }

    private func perform(_ operation: RequestOperation) {
        ongoing = operation

        operation.perform { [weak self] in
            self?.ongoing = nil

            // Perform the next request if the queue isn't empty
            ...
        }
    }
}

However, the downside here is that we’ll have to manually convert each request into a RequestOperation before adding it to a queue — and while that doesn’t add a lot of code at each call site, depending on the number of times that same conversion has to be done, it can end up feeling a bit like boilerplate.

Conclusion

While Swift offers an incredibly powerful type system that can help us avoid a large suite of bugs, it can sometimes feel a bit like we have to fight the system in order to use features like generic protocols. Having to do type erasure can initially seem like an unnecessary chore, but it also comes with benefits — like hiding specific type information from code that doesn’t need to concern itself with those types.

In the future, we might also see new features added to Swift to either automate the process of creating type erasing wrapper types, or to eliminate much of the need for it by enabling protocols to also be used as proper generics (for example being able to define a protocol like Request<Response, Error>, rather than only relying on associated types).

What flavor of type erasure that’ll be the most appropriate — either now or in the future — will of course depend a lot on the context, and whether or not our functionality can comfortably be performed within a closure, or if a full wrapper type or generic is a better fit for the problem.

What do you think? Do you usually have to use type erasure in your projects — or will it help you solve problems that you have previously faced? Let me know — along with your questions, comments or suggestions — on Twitter @johnsundell or by contacting me.

Thanks for reading! 🚀

Bindable values in Swift

String parsing in Swift