Using Type Erasure to Build a Dependency Injecting Routing Framework in Swift

Using Type Erasure to Build a Dependency Injecting Routing Framework in Swift

Update (2022-09-13): This article was written before Swift 5.7 and its introduction of many new useful type-erasure-oriented features. I recommend you to first read my article about those features before reading this one. The code of this article has not been updated to use the new features for preservation reasons.

With Swift being a very type-safe and namespaced language, you'll find that certain tasks are really hard to complete if at some point you can't determine the types that are being handled -- mostly when generics are involved. Using an automatic dependency injector as an example, let's see how generic arguments and closures can be "erased" to trick the compiler into compiling code that it would otherwise claim to be impossible (when it's clearly not!).

While this isn't your usual SwiftRocks-compiler-feature-deconstruction article, we'll take an exciting look at how the treatment of methods/closures as properties can be used in this context to bypass one of the Swift Compiler's most annoying compilation errors.

For Context: What's Type Erasure?

In programming languages with support for generics, Type Erasure is the process of abstracting constrained, generic types inside an unconstrained non-generic type that can be passed around freely.

If you don't know why that's necessary, consider that we have a Shape protocol and a few shapes:

protocol Shape {}

struct Circle: Shape {}
struct Triangle: Shape {}

Assuming that we're developing an image editor of some sort where the user can create shapes, we could store the user's latest created shapes like this:

var userShapes = [Shape]()
func userDidCreate(shape: Shape)
    userShapes.append(shape)
}

In Swift, this will work perfectly if there are no constraints on the Shape protocol, but what if it contained Self requirements -- like the presence of a method that checks if a shape is bigger than another shape of the same type?

protocol Shape {
    func isBigger(thanShape shape: Self) -> Bool
}

If that was the case, our approach to storing shapes would fall apart as the compiler is now unable to represent Shape without knowing the underlying type:

var userShapes = [Shape]() // Can't do!
// Error: Protocol 'Shape' can only be used as a generic constraint
// because it has Self or associated type requirements

While that's great for type safety, that's a bummer for our little Shapes app. Even though the presence of the underlying type is necessary to access their inner methods, they shouldn't be necessary to store them.

To fix this, we could store it as an [Any] array, but then we would be unable to cast it back to the original type, unless we tried all possibilities of Shapes. Yikes!

var userShapes = [Any]()
func userDidCreate<T: Shape>(shape: T) {
    userShapes.append(shape)
    // Works, but now we can't easily use the shapes.
}

The same problem surfaces if the protocol contained generic constraints or associated types.

Does the compiler really care about this?

There are multiple efficient ways to solve this problem, but before going into that, it's interesting to know why these errors exist -- is this really a problem for the compiler?

The answer is not really -- these errors only exist inside your IDE as a means to prevent you from making silly code mistakes. Think of access control properties like public and private: They are good examples of concepts that have absolute no impact on the final binary -- in the end, everything is accessible from everywhere, but inside your IDE, the compiler forces access control conventions to be followed so at the very least you are able to write code that is used the way you intended it to be used.

The issue here with generic constraints is similar -- the compiler does know what the underlying type of a Shape is in runtime, but because Swift is very type-safe, for safety reasons, if it can't be determined in compile time, it can't be done in the IDE. This is the complete opposite of Objective-C, where you could easily do whatever you wanted for the (very big) cost of compile-time safety.

Type Erasing with Closures

Update (2022-09-13): As mentioned in the beginning, Swift 5.7 introduced new features that replaced most use cases of this practice. You should now use the new any keyword when possible.

To bypass this problem, we can use the concept of Type Erasure. Instead of representing our stored shapes as Any, we abstract our constrained, boring Shape into an AnyShape type that has no constraints:

class AnyShape {
    let value: Any

    init<T: Shape>(_ shape: T) {
        self.value = shape
    }
}

Isn't this the same as defining the array as an [Any]?

In this case, yes. But consider what would happen if we needed to call our isBigger(_:) method from the array of shapes: With type erasure, we can abuse the fact that our init method is already constrained to create unconstrained abstracted versions of them, as closure properties!:

class AnyShape {
    let value: Any
    let isBigger: (AnyShape) -> Bool

    init<T: Shape>(_ shape: T) {
        self.value = shape
        self.isBigger = { any in
            return shape.isBigger(thanShape: any.value as! T)
        }
    }
}

Even though the Shape protocol is still constrained, putting one inside a AnyShape class will allow you to freely move it around in contexts where knowing the underlying type is unnecessary.

var userShapes = [AnyShape]()
func userDidCreate<T: Shape>(shape: T)
    userShapes.append(AnyShape(shape))
}

func sortSimilarShapes(_ shapes: [AnyShape]) -> [AnyShape] {
    return shapes.sorted { $0.isBigger($1) }
}

Isn't doing this unsafe because of the force-unwrapping?

Because type erasure with closures often relies on force casting into the proper generic type, doing this is indeed unsafe. In fact, one of the ways to solve this problem would be to simply treat everything as Any instead of putting constraints into the protocol in the first place, but this would be a bad choice for clear reasons -- Swift's enforces type safety precisely so your code works properly and is predictable.

Because type erasure relies on Any, you'll want to restrict its usage to places where the underlying type is predictable. Type erasures often only have the initializer exposed -- all usage is controlled internally to guarantee the force-unwraps will never fail. In the previous snippet for example, it would be assumed that the AnyShapes from sortSimilarShapes are all abstractions of the same type.

Using Type Erasure To Build a Dependency Injector

Using these concepts, let's see how we can build a dependency injector that can automatically fetch a feature's initializer and instantiate it with the relevant arguments.

To do so, we need an environment where this would be useful in first place. Dependency injectors are often used with routing mechanisms, allowing one UIViewController to push another one without having to explicitly instantiate and push it. In practice, this allows you to create apps where any screen can be pushed from anywhere without having to manually route dependencies forward or rely on singletons as the UIViewController still has a regular initializer -- but a dependency injector completely abstracts the process of calling it.

Let's assume that we have the concept of a Feature: A protocol that contains an associatedtype that answers what are the dependencies of this Feature and a build(_:) method that receives such dependencies and generates the UIViewController related to this feature:

public protocol Feature {
    associatedtype Dependencies
    static func build(dependencies: Dependencies) -> UIViewController
}

With this, let's create a hypothetical FeatureOne that depends on a hypothetical HTTPClient/Persistence module combo:

class HTTPClient { ... }
class Persistence { ... }

enum FeatureOne: Feature {
    struct Dependencies {
        let client: HTTPClient
        let persistence: Persistence
    }

    static func build(dependencies: FeatureOne.Dependencies) -> UIViewController {
        return FeatureOneViewController(dependencies: dependencies)
    }
}

In a regular iOS app architecture, assuming that we had a hold of instances of these dependencies, we could manually create and push this feature from the code:

func startFeatureOne() {
    let deps = FeatureOne.Dependencies(client: client, persistence: persistence)
    let feature = FeatureOne.build(dependencies: deps)
    navigationController?.pushViewController(feature, animated: true)
}

But what if I want to start FeatureOne from somewhere that doesn't have access to these dependencies? We could definitely use singletons, but our app would then take a massive hit in terms of testability. The ideal solution is to use this exact same structure, but abstract the process of initializing a feature's UIViewController.

Preparing the environment: Registering Dependencies

To abstract a Feature's initializer, we first need to be able to generate its dependencies. For this, we'll create a Dependency protocol:

protocol Dependency {}

extension HTTPClient: Dependency
extension Persistence: Dependency

In order to call a Feature's Dependencies struct's initializer, we need to have access to instances of these dependencies. Some dependency injectors allow you to determine how these instances are generated, but for simplicity, let's assume that we have one global instance that is reused for all features. A common way to store these global instances is through the use of a Store class that handles a dependency dictionary: since we're building a dependency injector for the purposes of managing our app's flow, let's assume that we have a RouterService class that is able to register Dependencies into a Store:

public final class RouterService: Dependency {

    var store = Store()

    public init() {
        register(dependency: self)
    }

    public func register(dependency: Dependency) {
        store.register(dependency)
    }

}

final class Store {

    var dependencies = [String: Any]()

    func get<T>(_ dependencyType: T.Type) -> T {
        let name = String(describing: dependencyType)
        return dependencies[name] as! T
    }

    func register(_ dependency: Dependency) {
        let name = String(describing: type(of: dependency))
        dependencies[name] = dependency
    }
}

(Notice how the RouterService itself is also a dependency -- this is because this is the interface that ViewControllers will use to navigate the app.)

In our AppDelegate, we can now register our dependencies and inject the service into our first screen (a fake splash, in this case):

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?
    let routerService = RouterService()

    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {

        routerService.register(HTTPClient())
        routerService.register(Persistence())

        let splash = SplashViewController(routerService: routerService)

        self.window = UIWindow(frame: UIScreen.main.bounds)
        self.window?.rootViewController = splash
        self.window?.makeKeyAndVisible()

        return true
    }
}

Now, how can we route from SplashViewController to FeatureOne without having direct access to its dependencies?

Type Erasing Initializers

Ideally, we want our RouterService to have a navigate(_:) method that can handle this only by having the desired Feature's type:

func navigate<T: Feature>(
    toFeature: T.Type,
    fromView viewController: UIViewController
) {
    let deps = ??????????
    let viewController = T.build(dependencies: deps)
    viewController.navigationController?.pushViewController(viewController, animated: true)
}

While the last two lines are straight-forward, we have a massive problem: because we're dealing with an associatedtype inside a generic method, we simply don't know what that Feature's Dependencies struct is. If we don't know exactly what the dependencies are, because of the generic constraints, we won't be able to create an instance of it.

However, from the beginning of the article, we've seen that we can use type erasure to abstract constrained types into unconstrained ones -- even generic closures! With this concept, we can abstract the Dependencies's constrained initializer into a closure that receives our RouterService's plain Store instead.

Let's take another look at the initializer of FeatureOne's Dependencies:

struct Dependencies {
    let client: HTTPClient
    let persistence: Persistence
}

In Swift, closures are types, and initializers/methods are closures. This means that in practice, FeatureOne.Dependencies(client:persistence:) can be stored and used as a (HTTPClient, Persistence) -> FeatureOne.Dependencies closure.

let initializer = FeatureOne.Dependencies.init
// (HTTPClient, Persistence) -> FeatureOne.Dependencies

Which, in generic terms, can be treated as a (T, U) -> V closure.

To type erase this into something our RouteService understands, we can abstract this closure into an unconstrained (Store) -> Any closure where the type erasure fetches the registered instances of T and U and uses them to create V (as Any). We can call this AnyInitializer:

public final class AnyInitializer {

    public let build: (Store) -> Any

    public init<T: Dependency, U: Dependency, V>(_ function: @escaping (T, U) -> V) {
        build = { store in
            let t: T = store.get(T.self)
            let u: U = store.get(U.self)
            return function(t, u)
        }
    }

}

Assuming that we have access to a Store that has the proper dependencies registered, we can now generate instances of any dependency initializer that fulfills the (T, U) -> V constraint!

let erasedInit = AnyInitializer(FeatureOne.Dependencies.init)
let dependencies = erasedInit(routerService.store) as! FeatureOne.Dependencies

But what if it doesn't fulfill the constraint? For example, a feature with three dependencies would need an additional generic argument, and a feature with no dependencies would only need the generic argument that represents the final result.

Unfortunately, Swift doesn't support variadic generics. This means that AnyInitializer needs to support multiple initializers with varied sizes:

public final class AnyInitializer {

    public let build: (Store) -> Any

    public init<T>(_ function: @escaping () -> T) {
        build = { store in
            return function()
        }
    }

    public init<T: Dependency, U>(_ function: @escaping (T) -> U) {
        build = { store in
            let t: T = store.get(T.self)
            return function(t)
        }
    }

    public init<T: Dependency, U: Dependency, V>(_ function: @escaping (T, U) -> V) {
        build = { store in
            let t: T = store.get(T.self)
            let u: U = store.get(U.self)
            return function(t, u)
        }
    }

    public init<T: Dependency, U: Dependency, V: Dependency, W>(_ function: @escaping (T, U, V) -> W) {
        build = { store in
            let t: T = store.get(T.self)
            let u: U = store.get(U.self)
            let v: V = store.get(V.self)
            return function(t, u, v)
        }
    }
}

While this looks weird, you may be interested to know that this is exactly how Apple handles SwiftUI's subviews. Unfortunately, this is a problem with the Swift language itself.

Finishing Touches: Retrieving AnyInitializer

To complete our RouterService's navigate(_:) method, we need to be able to retrieve a Feature's Dependencies AnyInitializer. We can do so by adding it to the Feature protocol:

public protocol Feature {
    associatedtype Dependencies
    static var dependenciesInitializer: AnyInitializer { get }
    static func build(dependencies: Dependencies) -> UIViewController
}

To implement it, our FeatureOne simply needs to pass around its Dependencies initializer:

static var dependenciesInitializer: AnyInitializer {
    return AnyInitializer(Dependencies.init)
}

...which finally allows our navigate(_:) method to access it, passing its inner Store as an argument.

func navigate<T: Feature>(
    toFeature: T.Type,
    fromView viewController: UIViewController
) {
    let deps = T.dependenciesInitializer.build(store) as! T.Dependencies
    let viewController = T.build(dependencies: deps)
    viewController.navigationController?.pushViewController(viewController, animated: true)
}

With the RouterService added as one of the Feature's argument, the relevant UIViewController can now push any other Feature without needing to have direct access to their dependencies.

routerService.navigate(toFeature: AnotherFeature.self, fromView: self)

As a nice complement, because AnyInitializer is constrained to Dependency arguments, you are guaranteed to never have misconfigured features as the compiler would fail to build otherwise.

Conclusion: Where to go from here?

The shown RouterService and its related components is a very simplified version of a navigation framework being tested at iFood. This can be evolved into a structure (which we use) that relies on Codable Routes and RouteHandlers instead of directly invoking features, allowing you to have a very smart deep link structure where your backend can dictate where the app's flow should go to.

Type Erasures are a very nice way to temporarily tell the compiler to "stop" type-checking your code. This allows you to conceive all sorts of complex structures without giving up type safety -- as long as you are careful with where they are used.