"Existentials" and "opaque types", but for concrete generic types?

Prior discussions:

The first thread linked above largely captures my interest. If you'll forgive some lengthy context, I'd like to include a specific use case where the "existential" variant of this concept felt like a good fit to me.

I recently wrote some code like this:

protocol Useful {
    // Details irrelevant
}

/// An identifier with a phantom type denoting an associated model type.
struct StreamIdentifier<Model: Useful> {
    var rawValue: String
}

/// Returns the models associated with the given stream identifier.
func query<Model>(for id: StreamIdentifier<Model>) -> [Model] {
    // Knows how to map from a stream identifier to the actual stream's data to return
}

Where the query function is a single access point for retrieving streams of these Useful things, which are all conceptually related despite having some differences in concrete models.

Alternate: lookup over a heterogeneous key-value store

Or, similar in structure, you can imagine a more strongly-typed lookup over a heterogeneous key-value store:

extension UserDefaults {
    struct Key<Value> {
        var rawValue: String
    }

    func value<Value>(for key: Key<Value>) -> Value? {
         // Performs lookup by raw value and casts accordingly
    }
}

To support testing the above, I wrote a command-line tool which prints information about returned Model instances, based on a StreamIdentifier's rawValue specified as an argument to the command line. Assuming a known registry of possible StreamIdentifiers (with various Model types), that lookup started like this:

func models(for argumentFromCLI: String) -> [any Useful] {
    switch argumentFromCLI {
    case StreamIdentifier.usefulThingOne.rawValue:
        return query(for: .usefulThingOne) // usefulThingOne :: StreamIdentifier<UsefulOne>
    case StreamIdentifier.usefulThingTwo.rawValue:
        return query(for: .usefulThingTwo) // usefulThingTwo :: StreamIdentifier<UsefulTwo>
    default:
        print("Unrecognized identifier \(argumentFromCLI)")
        return []
    }
}

and this works. Later I added a second command—similar in structure, but e.g. running some limitOneQuery instead of query. (Suppose limitOneQuery returns Model? instead of [Model].)

The number of known StreamIdentifiers is large enough in practice that duplicating the above switch statement in each command is undesirable, so it feels natural to split out the identifier lookup as a separate step from executing the query:

// This lookup can be shared among all commands:
func identifier(for argumentFromCLI: String) {
    switch argumentFromCLI {
    case StreamIdentifier.usefulThingOne.rawValue:
        return StreamIdentifier.usefulThingOne // usefulThingOne :: StreamIdentifier<UsefulOne>
    case Identifier.usefulThingTwo.rawValue:
        return StreamIdentifier.usefulThingTwo // usefulThingTwo :: StreamIdentifier<UsefulTwo>
    default:
        print("Unrecognized identifier \(argumentFromCLI)")
        return nil
    }
}

func models(for argumentFromCLI: String) -> [any Useful] {
    // Two steps:
    guard let id = identifier(for: argumentFromCLI) else {
        return []
    }
    return query(for: id)
}

...except you'll notice that I've omitted a return type from identifier(for:) above, because one can't actually be spelled out in the code as currently written that preserves enough information to then feed the result into queryStreamIdentifier<UsefulOne> and StreamIdentifier<UsefulTwo> are unrelated types!

We can dance around this by introducing a protocol:

protocol AnyStreamIdentifier<Model> {
    associatedtype Model: Useful
    var _concrete: StreamIdentifier<Model> { get }
}

extension StreamIdentifier: AnyStreamIdentifier {
    var _concrete: Self { self }
}

and opening an existential returned from identifier(for:):

func identifier(for argumentFromCLI: String) -> (any AnyStreamIdentifier)? {
    // Body identical
}

func models(for argumentFromCLI: String) -> [any Useful] {
    // Two steps:
    guard let id = identifier(for: argumentFromCLI) else {
      return []
    }

    // Open our existential so we can run `query` on a concrete `StreamIdentifier`
    func open(_ id: some AnyStreamIdentifier) -> [any Useful] {
      query(for: id._concrete)
    }
    return open(id)
}

This works, and is what I'm doing currently. However:

  • This strategy requires some ceremony in creating a new protocol which is intended to only ever have one conforming type.
  • This code feels unusually asymmetric with equivalent code using exclusively protocols (i.e. if StreamIdentifier itself were a protocol from the start, rather than a concrete type), despite concrete types otherwise usually being friendlier to work with.

In the example above, it feels natural to want a return type like "any StreamIdentifier", i.e. an "existential" but for a generic type with its type arguments unspecified, rather than for a protocol. The discussion in this thread from a couple years ago linked above suggests there's similar interest in the opaque equivalent.

Such types seem like they could impose constraints similar to existing opaque types and existentials with respect to which operations can be performed, as described in SE-0244, SE-0309 and SE-0352 (e.g. erasing covariant usage of type parameters to their upper bounds).

My questions are:

  • If we took the existing rules for existential and opaque types, and applied them to concrete generic types with rules like "given X<T>, treat any X as an existential as though X were a protocol with associatedtype T", would we encounter any new soundness holes?
  • Are there terms of art for the concepts described here specific to generic types, other than the terms we use for the analogous features of protocols? (If a higher-kinded type is "a generic type without its type arguments bound yet", these are like "a generic with its type arguments bound, but we're not sure with what".)
    • Do these concepts exist in any other programming languages?
3 Likes

You can think of an any P as packaging a value of type Self with the generic signature <Self where Self: P>, so let’s pretend we have this syntax:

any <Self where Self: P> Self

What you’re asking for is an existential where the head type is something other than Self. For example:

any <T where …> X<T>
5 Likes