Constraints on opaque and existential types are currently limited to protocol and protocol composition requirements on the underlying type. More advanced requirements, such as constraining an associated type, are currently not supported in the language. [Pitch 2] Light-weight same-type requirement syntax proposes to add a limited form of same-type requirement on "primary" associated types, but this feature is not a replacement for a more general syntax.
This post outlines a few ideas for expressing more sophisticated constraints on opaque and existential types and their implications/tradeoffs. This is not an exhaustive list - other ideas and brainstorming are welcome!
Constraints in a normal where clause
A seemingly obvious place to express constraints on opaque result types is in a trailing where clause, along with other constraints on the input type parameters.
func test() -> some Collection where Index == Int
This syntax could apply to both opaque and existential types. However, this syntax has two glaring ambiguities that, in my opinion, make this option unviable:
- It’s not clear whether the requirements in the
where
clause apply to the opaque type or the enclosing function. This creates an ambiguity between type parameters in scope, and associated types of the opaque result type.
Requiring a leading dot to reference associated types of the opaque result type will resolve the ambiguity between associated types and type parameters in scope, but that's very easy to forget and constrain the wrong type if one of the input type parameters/associated types has the same name. Repetition between in-scope type parameters and associated types is very common with Collection.Element
. This also doesn't offer a solution to the second ambiguity:
- There’s no way to disambiguate which associated type you want to constrain when you have multiple opaque types with associated types of the same name, e.g.
(some Collection, some Collection) where Index == Int
We could allow where
clauses in parenthesis to disambiguate and require distinct where
clauses for each opaque or existential type, but this could lead to extremely verbose function signatures including an arbitrary number of explicit where
clauses, such as (some Collection where Index == Int, some Collection where Index == Int) where Element: Comparable
.
Constraints in angle-brackets
A very popular suggestion is to write constraints on associated types in angle-brackets directly on the opaque or existential type, using leading-dot syntax to refer to associated types:
func test() -> some Collection<.Index == Int> { ... }
This syntax looks a lot like a where clause, but it’s more limited in what you can express with a general where clause. For example, this syntax does not allow expressing relationships between multiple opaque types, e.g. a pair of opaque types that are statically the same type.
It’s also not clear where this syntax would be supported. If it’s only allowed on opaque or existential types, i.e. types declared with some
or any
, it could be confusing that you can’t use this same syntax in other places where associated types are constrained, e.g. extension Collection<.Index == Int> { ... }
and other contexts where the type parameter constrained to the protocol has a name. If you can use it everywhere, then we’ve just added two different ways to write constraints on a single type parameter that have almost the same syntax:
extension Collection where Element == Int { ... }
extension Collection<.Element == Int> { ... }
It doesn't seem great to have two nearly-identical ways of writing the same thing. This would also create further distance between requirements on associated types versus type parameters, because presumably this would be invalid:
extension Array<.Element == Int> { ... } // error!
Result type parameter clause
Without a way to name an implicit type parameter declared by some
, enabling fully generalized constraints on opaque result types requires named result type parameters:
func test() -> <C> (C, C) where C: Collection, C.Index == Int { ... }
The first issue with this syntax is that it’s unclear how it would apply to existential types because there isn’t currently a way to name a type parameter and explicitly erase it while maintaining its requirements.
Next, this syntax is that it’s pretty inscrutable, especially in combination with input type parameters:
func groupedValues<C>(in collection: C) -> <Output> (even: Output, odd: Output)
where C: Collection, C.Element == Int, Output: Collection, Output.Element == Int
{ ... }
In addition to having another type parameter declaration clause in the middle of the function signature, untangling requirements between the input and output generic signatures specified in the same where clause adds implementation complexity and cognitive load for programmers writing and using such a declaration. Further, this extremely verbose syntax could “infect” callers that need to ascribe a type to the result of calling the function, e.g. to disambiguate between overloads, requiring a full generic signature in the type annotation:
let grouped: <Output> (even: Output, odd: Output) where Output: Collection, Output.Element == Int = groupedValues()
The generality of this syntax is great, but it would most likely be extremely onerous and confusing, as proven by similar issues with regular generic signatures today.
Requirement inference
SE-0328: Structural opaque result types made the deliberate decision to not support requirement inference on opaque types. However, enabling requirement inference for opaque types unlocks the full expressivity of a where
clause on the opaque type via generic type alias. For example:
typealias IntegerIndexed<C> = C where C: Collection, C.Index == Int
func test() -> IntegerIndexed<some Collection> { ... }
Requirement inference offers a solution to the “call-site infection” mentioned above, because the type alias can be used in a type annotation rather than writing the generic signature directly. However, this pro is also a con because additional constraints cannot be expressed at the declaration of the opaque type, requiring programmers to seek out the underlying typealias type in order to understand the API contract of an opaque result type. Finally, constraint inference via generic type aliases don’t offer a solution for existential types.
Please let me know your thoughts on the ideas above, and any other ideas that I didn't think of!