Swift parameter packs - Implementing a lightweight dependency injection

Dependency injection is key to app development as it enables developers to benefit from modularity throughout the codebase while facilitating unit testing.

Although there are many good libraries such as Swinject or Resolver, a tailored lightweight dependency injection mechanism can often be sufficient to register and resolve services.

The first part of this article will be dedicated to the implementation of a lightweight dependency injection system which will then be improved using the latest Swift feature called parameter packs.

— Building a dependency injection system

To implement a lightweight dependency injection system, we need to focus on two main functions : register and resolve.

These functions are self-explanatory, one to register a service, the other to resolve the service at callsite.

To start with, let’s create our injector singleton object which will store our registrable services.

public final class Injector {
    static private(set) public var shared = Injector()
    private var services: [ServiceKey: ServiceEntryProtocol] = [:]
    
    // MARK: - Object lifecycle
    
    private init() {}
}

The services property provides a basic key-value storage where ServiceKey identifies the service we need to retrieve and ServiceEntryProtocol holds the service initialization logic through a factory property.

struct ServiceKey {
    let serviceType: Any.Type
    let argumentsType: Any.Type
}

extension ServiceKey: Hashable {
    public func hash(into hasher: inout Hasher) {
        ObjectIdentifier(serviceType).hash(into: &hasher)
        ObjectIdentifier(argumentsType).hash(into: &hasher)
    }
    
    static func == (lhs: ServiceKey, rhs: ServiceKey) -> Bool {
        return lhs.serviceType == rhs.serviceType && lhs.argumentsType == rhs.argumentsType
    }
}

typealias FunctionType = Any

protocol ServiceEntryProtocol: AnyObject {
    var factory: FunctionType { get }
    var serviceType: Any.Type { get }
}

public final class ServiceEntry<Service>: ServiceEntryProtocol {
    let serviceType: Any.Type
    let argumentsType: Any.Type
    let factory: FunctionType
    
    init(serviceType: Service.Type, argumentsType: Any.Type, factory: FunctionType) {
        self.serviceType = serviceType
        self.argumentsType = argumentsType
        self.factory = factory
    }
}

ServiceEntry (which conforms to ServiceEntryProtocol) store three essential informations to our mechanism:

Our register function is now able to construct a ServiceKey and a ServiceEntry.

public final class Injector {
    static private(set) public var shared = Injector()
    private var services: [ServiceKey: ServiceEntryProtocol] = [:]
    
    private init() {}
    
    @discardableResult
    public func register<Service, Arg1>(_ serviceType: Service.Type, factory: @escaping (Arg1) -> Service) -> ServiceEntry<Service> {
        return _register(serviceType, factory: factory)
    }
    
    fileprivate func _register<Service, Arguments>(_ serviceType: Service.Type, factory: @escaping (Arguments) -> Any) -> ServiceEntry<Service> {
        let key = ServiceKey(serviceType: Service.self, argumentsType: Arguments.self)
        let entry = ServiceEntry(
            serviceType: serviceType,
            argumentsType: Arguments.self,
            factory: factory
        )
        services[key] = entry

        return entry
    }
}

Before moving forward to the resolve function implementation, let’s make sure we can actually register a service.

For the purpose of this article, we’ll be creating a ViewModel class that holds an unowned reference to a view controller.

class ViewModel {
    unowned let viewController: ViewController
    
    init(viewController: ViewController) {
        self.viewController = viewController
    }
}

Registering our ViewModel is pretty straightforward as we simply need to call the dedicated function.

Injector.shared.register(ViewModel.self) { viewController in
    ViewModel(viewController: viewController)
}

Compiler is able to infer the argument type since we’re basically passing the init of our object in the closure, here’s another way of writing it:

Injector.shared.register(ViewModel.self, factory: ViewModel.init)

Now that we’ve registered our ViewModel service, we need to implement our resolve function so we can actually use it.

resolve asks for a service and an injectable argument.

public func resolve<Service, Arg1>(argument: Arg1) -> Service {
    typealias FactoryType = ((Arg1)) -> Any
    return _genericResolve(serviceType: Service.self) { (factory: FactoryType) in factory((argument)) }
}

To resolve our service, we first need to retrieve the entry from the services key-value storage using a ServiceKey. Service type and arguments types must match.

private func _genericResolve<Service, Arguments>(serviceType: Service.Type, invoker: @escaping ((Arguments) -> Any) -> Any) -> Service {
    var resolvedInstance: Service?
    var type: Any.Type
    
    type = Service.self
    
    let key = ServiceKey(serviceType: type, argumentsType: Arguments.self)

    if let entry = services[key] {
        resolvedInstance = resolve(entry: entry, invoker: invoker)
    }

    if let resolvedInstance = resolvedInstance {
        return resolvedInstance
    } else {
        fatalError("You need to register concrete type for \(Service.self)")
    }
}

If we do have a match, we’ll be able to invoke the closure and trigger the initialization of our service.

fileprivate func resolve<Service, Factory>(entry: ServiceEntryProtocol, invoker: (Factory) -> Any) -> Service? {
    let resolvedInstance = invoker(entry.factory as! Factory)
    return resolvedInstance as? Service
}

Assuming we already did the registration, let’s now try to resolve our ViewModel service.

class ViewController: UIViewController {
    var viewModel: ViewModel!

    override func viewDidLoad() {
        super.viewDidLoad()

        viewModel = Injector.shared.resolve(argument: self)
    }
}

Unfortunately, this code will result in a crash.

Fatal error: You need to register concrete type for Optional<ViewModel>

It does actually make sense as we registered a ViewModel service and we’re trying to resolve an Optional<ViewModel> service (which are both different). Don’t forget that service type and arguments must match.

We need to tackle this issue as we may have optional services that we would still want to resolve whether the type if optional or not.

To properly resolve our service even it is of Optional type, we need to retrieve the Wrapped value and try to resolve it instead of resolving an Optional service (which doesn’t make sense anyways)

protocol OptionalProtocol {
    static var wrappedType: Any.Type { get }
}

extension Optional: OptionalProtocol {
    public static var wrappedType: Any.Type {
       Wrapped.self
     }
}

Now, inside the _genericResolve function, we can add the following code (right before the key constant) to safely unwrap an optional service (if any) and access the wrapped service.

if let optionalType = Service.self as? OptionalProtocol.Type {
    type = optionalType.wrappedType
} else {
    type = Service.self
}

So, our function now looks complete.

private func _genericResolve<Service, Arguments>(serviceType: Service.Type, invoker: @escaping ((Arguments) -> Any) -> Any) -> Service {
    var resolvedInstance: Service?
    var type: Any.Type
    
    if let optionalType = Service.self as? OptionalProtocol.Type {
        type = optionalType.wrappedType
    } else {
        type = Service.self
    }
    
    let key = ServiceKey(serviceType: type, argumentsType: Arguments.self)

    if let entry = services[key] {
        resolvedInstance = resolve(entry: entry, invoker: invoker)
    }

    if let resolvedInstance = resolvedInstance {
        return resolvedInstance
    } else {
        fatalError("You need to register concrete type for \(Service.self)")
    }
}

ViewModel instance can now be correctly resolved.

viewModel = Injector.shared.resolve(argument: self)

While above implementation works fine, our lightweight dependency injection system currently only supports injection for services with a single argument.

To cover most cases, we most likely need to add more resolve and register functions with multiple arguments.

Let’s add the functions so we can support multiple arguments when registering our services.

@discardableResult
public func register<Service, Arg1, Arg2, Arg3, Arg4, Arg5>(_ serviceType: Service.Type, factory: @escaping (Arg1, Arg2, Arg3, Arg4, Arg5) -> Service) -> ServiceEntry<Service> {
    return _register(serviceType, factory: factory)
}

public func resolve<Service, Arg1, Arg2, Arg3, Arg4, Arg5>(arguments arg1: Arg1, _ arg2: Arg2, _ arg3: Arg3, _ arg4: Arg4, _ arg5: Arg5) -> Service {
    typealias FactoryType = ((Arg1, Arg2, Arg3, Arg4, Arg5)) -> Any
    return _genericResolve(serviceType: Service.self) { (factory: FactoryType) in factory((arg1, arg2, arg3, arg4, arg5)) }
}

Better but not optimal yet as it forces us to duplicate our register and resolve public functions to support as many arguments as we need.
Let’s improve this.

- Meet parameter packs

Parameter packs are variadic generics that can support multiple types (vs. a single type before) so instead of creating multiple register and resolve functions with different generic types as arguments, we’ll be using only one resolve and register function to manage our services.

Before starting, please note that this is an experimental feature that required the following flag in a Swift package :

swiftSettings: [.enableExperimentalFeature("VariadicGenerics")])

A first adjustment we need to make is to remove the ServiceEntryProtocol as variadic generics are not yet supported with protocols (on Xcode beta 2). Our protocol must be replaced by Any (not ideal but the only solution for now).

public final class Injector {
    static private(set) public var shared = Injector()
    private var services: [ServiceKey: Any] = [:]
    
    // MARK: - Object lifecycle
    
    private init() {}
}

Now the ServiceEntry class needs some adjustments to use parameter packs.

The repeat each syntax is used to declare variadic generics.

typealias FunctionType<each Arguments> = (repeat each Arguments) -> Any

public final class ServiceEntry<each Arguments> {
    let argumentsType: Any.Type
    let factory: FunctionType<repeat each Arguments>
    
    // MARK: - Object lifecycle
    
    init(argumentsType: Any.Type, factory: @escaping FunctionType<repeat each Arguments>) {
        self.argumentsType = argumentsType
        self.factory = factory
    }
}

The register methods also needs some tweaks.

public final class Injector {
    static private(set) public var shared = Injector()
    private var services: [ServiceKey: Any] = [:]
    
    // MARK: - Object lifecycle
    
    private init() {}
    
    
    // MARK: - Register
    
    @discardableResult
    public func register<Service, each Argument>(
        _ serviceType: Service.Type,
        factory: @escaping (repeat each Argument) -> Service
    ) -> ServiceEntry<repeat each Argument> {
        _register(serviceType, factory: factory)
    }
    
    
    fileprivate func _register<Service, each Arguments>(
        _ serviceType: Service.Type,
        factory: @escaping (repeat each Arguments) -> Any
    ) -> ServiceEntry<repeat each Arguments> {
        let argumentsType = Mirror(reflecting: (repeat (each Arguments).self)).subjectType
        let key = ServiceKey(serviceType: Service.self, argumentsType: argumentsType)
        
        let entry = ServiceEntry(
            argumentsType: argumentsType,
            factory: factory
        )
        
        services[key] = entry
        
        return entry
    }
}

The public func register<Service, each Argument> will allow us to pass multiple arguments (types can be different) when registering our services.

In other words, each Argument allows our register function to accept one, two or more arguments with different types.

Let’s move on to the resolve methods, one major difference here is that we need to type cast our dictionary value to ServiceEntry (since we had to remove ServiceEntryProtocol).

Hopefully protocol using parameter packs will be supported in the future.

// MARK: - Resolve

public func resolve<Service, each Arguments>(arguments: repeat each Arguments) -> Service {
    _genericResolve(serviceType: Service.self) { factory in
        factory(repeat each arguments)
    }
}

private func _genericResolve<Service, each Arguments>(
    serviceType: Service.Type,
    invoker: @escaping ((repeat each Arguments) -> Any) -> Any
) -> Service {
    var type: Any.Type
    
    if let optionalType = Service.self as? OptionalProtocol.Type {
        type = optionalType.wrappedType
    } else {
        type = Service.self
    }
    
    let argumentsType = Mirror(reflecting: (repeat (each Arguments).self)).subjectType
    let key = ServiceKey(serviceType: type, argumentsType: argumentsType)
    
    if let entry = services[key] as? ServiceEntry<repeat each Arguments>,
       let resolvedInstance: Service = resolve(entry: entry, invoker: invoker) {
        return resolvedInstance
    } else {
        fatalError("You need to register concrete type for \(Service.self)")
    }
}

fileprivate func resolve<Service, each Arguments>(
    entry: ServiceEntry<repeat each Arguments>,
    invoker: @escaping ((repeat each Arguments) -> Any) -> Any
) -> Service? {
    let resolvedInstance = invoker(entry.factory)
    return resolvedInstance as? Service
}

Finally, let’s register and resolve our ViewModel using parameter packs.

class ViewController: UIViewController {
    var viewModel: ViewModel!

    override func viewDidLoad() {
        super.viewDidLoad()
        
        Injector.shared.register(ViewModel.self) { viewController, isActive, randomNumber in
            ViewModel(viewController: viewController, isActive: isActive, randomNumber: randomNumber)
        }
        
        viewModel = Injector.shared.resolve(arguments: self, Bool.random(), Int.random(in: 1...4))
    }
}

Please note that if the number of arguments (or their types) is not correct when resolving the services, app will crash making sure everything is properly done.

Our dependency injection system now provides a rather simple, intuitive mechanism using the latest Swift feature, to register and resolve services on the fly without the cumbersomeness of a third-party library.

Thanks for reading!