Prior discussions:
- [Pitch] Use “some” to express “some specialization of a generic type”
- Syntax for existential type as a box for generic type
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 StreamIdentifier
s (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 StreamIdentifier
s 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 query
— StreamIdentifier<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>
, treatany X
as an existential as thoughX
were a protocol withassociatedtype 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?