How to type-erase a Swift PAT that exposes an `init`?

Questions are:

  • how to type-erase a PAT (a Protocol with Associated-Type) that exposes an init method? The PAT being SpecializedContribution and my tentative being AnySpecializedContribution in the code example below.
  • how to type-erase a PAT that exposes the metatype of the associatedtype? The PAT being SpecializedContributionConfiguration and my tentative being AnySpecializedContributionConfiguration in the code example below.

I realized this is 2 questions instead of 1 but I have the intuition that they are tied together and could both be resolved by playing with metatypes.

The idea of the code below is to allow sub-modules to vend "contribution configurations" to the main-app module but to let the main-app module actually init the contributions. This is notable useful to enforce, at compile time, that sub-modules don't cheat and rely on contribution singletons internally.

public protocol Contributing {
    associatedtype Dependencies

    init(dependencies: Dependencies)
}

public protocol ContributionConfigurating {
    associatedtype Contribution: Contributing

    var type: Contribution.Type { get }
    var dependencies: Contribution.Dependencies { get }
}

public protocol SpecializedContribution: Contributing {
    var anAPI: Int { get }
}

public protocol SpecializedContributionConfiguration: ContributionConfigurating where Contribution: SpecializedContribution {}

// If I build a "configuration provider" that returns a single configuration, it works like a charm
// thanks to opaques types. But I want the provider to return multiple configurations.
public protocol SingleSpecializedContributionConfigurationProviding {
    associatedtype ConcreteContributionConfiguration: SpecializedContributionConfiguration

    func contributionConfiguration() -> ConcreteContributionConfiguration
}

class SingleSpecializedContributionConfigurationProvider: SingleSpecializedContributionConfigurationProviding {
    public func contributionConfiguration() -> some SpecializedContributionConfiguration {
        // <code that actually returns a concrete implementation of a SpecializedContributionConfiguration>
    }
}

// First attempt, just return the protocol. This doesn't work, a classic array-with-PATs issue.
// No surprise so far.
public protocol MultipleSpecializedContributionConfigurationsProviding {
    func contributionConfigurations() -> [SpecializedContributionConfiguration]
}

// Second (and current) attempt, type-erase the configurations.
// Problems?
// 1/ The configuration protocol vends a metatype.
// 2/ The contribution protocol, which I'd have to type-erase eventually, exposes an init method.
public protocol TypeErasedMultipleSpecializedContributionConfigurationsProviding {
    associatedtype ConcreteContributionConfiguration: SpecializedContributionConfiguration

    func contributionConfigurations() -> [ConcreteContributionConfiguration]
}

class TypeErasedMultipleSpecializedContributionConfigurationsProvider: TypeErasedMultipleSpecializedContributionConfigurationsProviding {
    public func contributionConfigurations() -> [AnySpecializedContributionConfiguration] {
        [
            AnySpecializedContributionConfiguration(
                // Let's assume this is defined somewhere
                FooContributionConfiguration()
            ),
            AnySpecializedContributionConfiguration(
                // Let's assume this is also defined somewhere
                BarContributionConfiguration()
            )
        ]
    }
}

public struct AnySpecializedContributionConfiguration: SpecializedContributionConfiguration {
    public typealias Contribution = AnySpecializedContribution

    public var type: Contribution.Type {
        // I don't know what to store here.
        AnyContribution.self
    }

    public var dependencies: Contribution.Dependencies {
        _dependencies()
    }

    private let _dependencies: () -> Contribution.Dependencies

    init<T: SpecializedContributionConfiguration>(_ configuration: T) {
        _dependencies = {
            configuration.dependencies
        }
    }
}

public struct AnySpecializedContribution: SpecializedContribution {
    public typealias Dependencies = Any

    private let _anAPI: () -> Int

    public var anAPI: Int {
        _anAPI()
    }

    init<T: SpecializedContribution>(_ contribution: T) {
        _anAPI = {
            contribution.anAPI
        }
    }

    // How to "type-erase the Contributing.init(dependencies: Dependencies)"?
}

My understanding is that I'm basically trying to fight against the current lack of generalized existentials, by relying on type-erasure. But the type-erasure pattern seems to presents its own limit, leaving me in an uncomfortable situation.

From there, I wonder if:

  • I should stick in this type-erasure direction, but then how?
  • I should explore a different work-around to the lack of generalized existentials?

I would try to split off the init() requirement into it's own protocol to which the type eraser doesn't conform. Otherwise, you could construct an instance of some default type.

I don't think there is a way if you want the type-erased metatype to conform to some protocol bound that itself has static (or init) requirements. In your example, var type: Contribution.Type has to be implemented in the type eraser by returning AnySpecializedContribution.self. However a struct metatype carries no state, so the static methods on that metatype have to be implemented without knowing the original concrete type that was erased.

If your protocol was instead a factory, like

public protocol ContributingFactory {
  associatedtype InstanceType: Contributing

  func construct(with: InstanceType.Dependencies) -> InstanceType
}

Then an AnyContributingFactory instance could carry the erased type information.

Thanks a lot for your prompt answer @Slava_Pestov.

Being aware of the rest of the interface (such as SpecializedContribution.anAPI) is a good thing, but being aware of the init itself is also quite important in my case. Indeed, the main-app module will receive the Contributing types and be in charge of instantiating them.

In a world where I go for a bit of type-unsafety, your suggestion is safer than just using Any so this is a great step already, thank you.

Sorry, I'm not sure I understand what you mean here.

If your protocol was instead a factory

If I understand your suggestion well, doesn't it defeat the purpose of this whole architecture, which is for sub-modules to "delegate" the instanciation of contributions to the main-app module to avoid that sub-modules feel like returning contribution singletons. Indeed, if it's a sub-module responsibility to implement the construct method, they can return any instance they want, including a singleton.