Constraining types to protocols

I'm writing a little library for managing dependencies and I'm running into a weird language bit that I think should work but gives me extremely unhelpful errors.

I defined a protocol that mirrors UserDefaults and I want to integrate it with how I store other dependencies (like keychain and files).

protocol DefaultsStorage {
    func value(forKey key: String) -> Any?
    func setValue(_ newValue: Any?, forKey key: String)
}

protocol DependencyKey {
    associatedtype Value
    associatedtype Storage
    func value(in storage: Storage) -> Value?
    func setValue(_ newValue: Value?, in storage: Storage)
}

/// This is a simplification of how I aim to use the protocols.
@propertyWrapper struct Dependency<Value, Storage> : DynamicProperty {

    @State private var cache = DependencyCache<Value>()
    let key: any DependencyKey<Value, Storage>
    let storage: Storage
    
    var wrappedValue: Value? {
        get {
            /// over simplified for the example
            /// this would actually happen once in update()
            cache.value ?? key.value(in storage: storage)
        }
        nonmutating set {
            cache.update(newValue)
            key.setValue(newValue, in: storage)
        }
    }
}

However when I write a simple value to implement DependencyKey, I'm locked into choosing a concrete type for Storage.

struct SystemDefaultsKey<Value> : DependencyKey {
    typealias Storage = // Something must go here?
    let name: String
    
    func value(in storage: Storage) -> Value? {
        storage.value(forKey: name) as? Value
    }
    ///...
}

I've tried creating a refinement of DependencyKey that specifies a specific type of storage

protocol DefaultsDependencyKey: DependencyKey where Storage: DefaultsStorage {}

However, In my concrete types I still have to specify a concrete Storage type (UserDefaults for simplicity). Using any DefaultsStorage doesn't work either because it can't conform to DefaultsStorage.

When using this protocol in a property wrapper, I use @Environment to access the storage property to pass to the key. However, all my keys become useless because the storage type is now Environment<UserDefaults>. I want to instead confine Storage to a specific protocol.

Protocol constraints seems like it should be the solution but the compiler warnings are not helpful. I tried to express it using typical generic clause and using the new some keyword (combined here in one example).

protocol DependencyKey {
    associatedtype Value
    associatedtype Storage: Protocol
    func value(in storage: some Storage) -> Value?
    /// or... that regular generic clause
    func setValue<T: Storage>(_ newValue: Value?, in storage: T)
}

Which gives me two errors:

Type 'some Storage' constrained to non-protocol, non-class type 'Self.Storage'

and:

Type 'T' constrained to non-protocol, non-class type 'Self.Storage'

How does one constrain a generic type to be a protocol? Clearly the Protocol keyword is useless in this context. I've tried protocol and that doesn't even parse enough to give helpful errors.

The type system seems to require a protocol constrained type but no way to actually constrain a type to be a protocol. What am I missing here?

protocol SomeProtocol {
    associatedtype Value
    associatedtype Container: // ???
    func doWork<T: Container>(using container: T) -> Value?
}

How does one define a protocol that can define constraints for functions they require? Am I holding it wrong or am I trying to use a feature that doesn't come with this model?

I think you don't want storage as an associated type here; you want something like

protocol DependencyKey {
    associatedtype Value
    func value<Storage: DefaultsStorage>(in storage: Storage) -> Value?
    func setValue<Storage: DefaultsStorage>(_ newValue: Value?, in storage: Storage)
}
2 Likes

This feature does not exist--today, the right-hand side of a conformance requirement must name a concrete protocol, and not some other type parameter. It is mentioned as a future direction in the Generics Manifesto as "Generalized Supertype Constraints", amusingly under the heading "Minor extensions", but in reality this would require a complete overhaul of the generics model.

2 Likes

That's unfortunate. Swift seems to be forever stuck in the swamps of fixing structured concurrency, any chance stuff like this in the Manifesto will ever actually be implemented or looked at again? It was written in 2016...

Very handy link, thanks. I've read it a few times now but didn't quite understand what it meant.

Currently, supertype constraints may only be specified using a concrete class or protocol type. This prevents us from abstracting over the supertype.

protocol P { 
   associatedtype Base 
   associatedtype Derived: Base
}

In the above example Base may be any type. Derived may be the same as Base or may be any subtype of Base. All subtype relationships supported by Swift should be supported in this context including (but not limited to) classes and subclasses, existentials and conforming concrete types or refining existentials, T? and T, ((Base) -> Void) and ((Derived) -> Void), etc.

Generalized supertype constraints would be accepted in all syntactic locations where generic constraints are accepted.

To be clear, when the document references "supertype constraints" they mean associatedtypes, right?

I get where you're going, but this would mean that DependencyKey would need to know about DefaultsStorage. One of the main ideas was to have the two protocols decoupled.

Unfortunately, as Slava states, it's not possible with today's Swift. I appreciate your ideas though, thanks.

I feel like you want mutually exclusive things, then?

  • If you want DependencyKey to be able to use any storage, then it needs to know something (aka, the superprotocol) that that all storage conform to)
  • If you want DependencyKey to be able only to use specific storage, then you can't use an arbitrary dependency key with an arbitrary storage.

There is a missing language feature here, but even if you could do what you want, what would it buy you?

protocol DependencyKey<Value, StorageProtocol> {
    associatedtype Value
    associatedtype StorageProtocol: AnyProtocol // hypothetical syntax
    func value<Storage: StorageProtocol>(in storage: Storage) -> Value?
    func setValue<Storage: StorageProtocol>(_ newValue: Value?, in storage: Storage)
}

@propertyWrapper
struct Dependency<Value,  StorageProtocol: AnyProtocol> : DynamicProperty {
    let key: any DependencyKey<Value, StorageProtocol>
    let storage: any StorageProtocol

    // ...
}

Now each concrete DependencyKey has to implement value(in:) and setValue_:in:) — if you can do that, you could have made an adapter from StorageProtocol to DependencyStorage in the first place, and gone with my first suggestion?

2 Likes

In fairness, I left a lot of extraneous protocols and types out of my example in order to be relevant to the language features and errors I was encountering. I have explored solutions like the one you described but they didn't fit my use case. This isn't a failing in your code or ideas, they're just trying to answer questions with a lot more hidden complexity than I can explain here.

Again, I appreciate the discussion. It all helps in the end ;)