Can we ever further constrain generic types locally?

I'm facing an issue where I run out of ideas on how I could solve it without sacrificing access control. The example is very much simplified, but in the real codebase it would require me to expose a lot of internal types to public API if I'd switch StorageInterface to public.

public protocol BaseInterface {
  associatedtype Mode: Equatable
}

internal protocol StorageInterface: BaseInterface {
  associatedtype Storage
}

internal struct Core<Interface: StorageInterface> {}

public struct GenericType<Interface: BaseInterface> {
  // Non-protocol, non-class type 'Interface'
  // cannot be used within a protocol-constrained type
  internal let core: Core<Interface & StorageInterface>

  // Ultimately I also would want to be able to express this
  internal init(_ interface: Interface & StorageInterface) {
    self.core = Core(with: interface)
  }
}

I could solve it if I had this feature, but it isn't there yet:

Has anyone a better solution in mind?

I could go the public route, but this is really the last resort.


cc @Douglas_Gregor @Slava_Pestov do you think we could extend & existential composition on non-class types as well? The only workaround I could think of forced me to make the internal protocol public, which cascaded and forced me to expose a lot of internal types and implementation detail.

Actually after further thoughts I don't think the above would make any sense.

  • Core<Interface & StorageInterface> wouldn't work because the existential does not conform to itself.
  • As per the opaque types thread an existential does not play well with generics. That means Core will reject it.
  • An opaque type here doesn't seem like the right solution.

That said I think we're missing of an ability of further constrain types locally. I prefer to put a where clause on existentials in a type alias.

public struct GenericType<Interface: BaseInterface> {

  typealias InterfaceWithStorage = Interface where Interface: StorageInterface
  // I also would like a different way of expressing it
  typealias InterfaceWithStorage = Interface: StorageInterface

  internal let core: Core<InterfaceWithStorage>

  internal init(_ interface: InterfaceWithStorage) {
    self.core = Core(with: interface)
  }
}

You're really close to having this:

public struct GenericType<Interface: BaseInterface> {}

extension GenericType where Interface: StorageInterface {
  internal let core: Core<Interface>

  internal init(_ interface: Interface) {
    self.core = Core(with: interface)
  }
}

except that extensions can't add stored properties. And if you think about it, that's the reason this can't "just work" today: you're saying the struct has a property that may or may not be valid depending on what the generic parameter is. It's not impossible to design such a feature, but it does make things super tricky.

That would be another way of expressing it, but it feels far more complicated than it reads. In my case all Interface types are known to have an associated type for a Storage, but that is completely an implementation detail which the library user doesn't even need to know about. Since the init is also completely internal, the access to the storage type should be safe without being exposed in any way.

There is also yet another way I could think of that might solve my issue. A derived from of private conformances:

public struct GenericType<Interface: BaseInterface> where internal Interface: StorageInterface {
  internal let core: Core<Interface>

  internal init(_ interface: Interface) {
    self.core = Core(with: interface)
  }
}

That's not how generics work. Generics say the client can always choose the type, so if you say GenericType<Interface: BaseInterface>, I can make a GenericType<SomeInterfaceThatDoesNotConformToStorage>, and the compiler can't stop me. Sure, I can't instantiate it, but I can put extension methods on it.

We don't yet have a notion of "this thing is generic on a protocol you can't see", and I'm not sure what the implications would be. Public protocols with non-public requirements are a less ambitious answer to this problem, but that's not quite as flexible.

To add on that, to ensure that the compiler doesn't go crazy when the library user creates a custom Interface type which only conforms to BaseInterface and types somewhere GenericType<CustomInterface>, even though it's impossible for the user to construct it, the BaseInterface from my point of view should be sealed.

cc @anandabits isn't that a good use-case for sealed protocols we all search for?

To sum up, ideally I would like BaseInterface be available for the library user to use as an 'interface', hence it should be sealed. A sealed protocol would allow the library to statically guarantee further internal constraints of types that already do conform to BaseInterface.

// A small hypothetical module
sealed protocol BaseInterface {
  associatedtype Mode: Equatable
}

internal protocol StorageInterface: BaseInterface {
  associatedtype Storage
}

internal struct Core<Interface: StorageInterface> { ... }

public struct GenericType<Interface: BaseInterface> where
  internal Interface: StorageInterface
{
  internal let core: Core<Interface>
  internal init(storage: Interface.Storage) { ... }
}

extension GenericType where Interface == ConcreteInterface {
  static func new() -> GenericType { ... }
}

public ConcreteInterface: StorageInterface {
  public enum Mode { ... }
  internal struct Storage { ... }
}

You could do this as long as the library code that needs to see core can always constrain to Interface: StorageInterface:

public struct GenericType<Interface: BaseInterface> {
    private let _core: Any
}

extension GenericType where Interface: StorageInterface {
    internal var core: Core<Interface> {
        return _core as! Core<Interface>
    }

    internal init(_ interface: Interface) {
        _core = Core(with: interface)
    }
}

However, you might have some trouble because your public API won't include that constraint so core won't be visible. There are dispatch techniques that could be applied in this case but they are not trivial.

It sounds like what you really want here is a sealed protocol with a non-public associated type requirement. I don't know what you're doing well enough to evaluate whether this is a good design, but I think it's what you're trying to emulate. You're just using StorageInterface as a way to add new requirements that are only visible inside the library.

The example you just showed can work if I erase the core type during initialization. I just don't like it, because the internal API isn't statically enforced anymore. Furthermore when I wrote the original example I used a struct for the GenericType for its simplicity, in reality it's a final class GenericType (just called differently).

Something along the lines, yes. Each Interface type has it's own Storage type associated with it which can have different or shared API surface. Accessing it is then done through the main GenericType using a conditional extension.

// For example
extension GenericType {
  public var name: Name {
    return core.interface.storage.name
  }
}

Can you explain what you mean by this? You only have to trust code in the same file that lives in GenericType. When you're doing library development sometimes you have to live with compromises like this. I don't like it either and hope the type system is powerful enough to avoid the need for things like this in more cases, but it's really not that bad (at least in the stripped down example).

The example you posted doesn't have any constraints on the extension so it's not clear what it is showing. Storage in your example doesn't have any constraints so name wouldn't be available. Do the constraints you're describing make that available?

We do -- existential types! Generalized existentials and GADTs take it to its logical conclusion.

Those say that you can't see the concrete type, but not that you can't see all of the protocols.

1 Like

Ah, right. What would it even mean to use a type parameter standing for an unknown protocol abstractly? I guess you could use it for is checks...

Obviously the first implication is that if you can't see the constraint you can't meet the constraint with your own types. Beyond that, if there was a way for the library to say type X defined in the library meets opaque constraint C then users would be able to form G<X> where G requires it's argument to meet constraint C.

We do know that people have good use cases for non-public protocol requirements and this could potentially be a more general feature that would server those use cases. On the other hand, I don't know whether the complexity this entails would pay for itself or not and if non-public requirements really is the primary use case it would be better to support those directly instead.