Associated Type Disambiguation
- Proposal: SE-NNNN
- Authors: Jonathan Gilbert
- Review Manager: TBD
- Status: Draft / experimental (
-enable-experimental-feature AssociatedTypeDisambiguation) - Implementation: swiftlang/swift#89743
- Review: (pitch)
Introduction
In 2016, @Noah_Blake pitched adding name-spacing to associated types. Back then, just as now, you can't use dot-syntax qualifiers to disambiguate same-named associated types:
protocol Foo {
associatedtype Baz
}
protocol Bar {
associatedtype Baz
}
struct Alice: Foo, Bar {
typealias Foo.Baz = Int // does not compile today: you can't use
typealias Bar.Baz = String // dot-syntax to disambiguate here
}
For some of us, this may seem unexpected, since it's normal to disambiguate same-name collisions with dot-syntax like:
struct Foo {
typealias Baz = Int
}
struct Bar {
typealias Baz = String
}
struct Alice {
let x: Foo.Baz
let y: Bar.Baz
}
Obviously that's not the exact same situation, but the underlying issue is that when a type conforms to two protocols that each declare an associated type with the same name, Swift today forces a single witness to satisfy both. There is no way to say "this protocol's Output is A, but that protocol's Output is B," even though the two requirements are completely independent and are already stored separately at the ABI level (each conformance has its own witness table with its own slot).
This proposal lets a conforming type give distinct witnesses to same-named associated types from different protocols, using a protocol-qualified type alias, and lets call sites refer to a specific protocol's associated type unambiguously:
protocol Parser { associatedtype Output }
protocol Renderer { associatedtype Output }
struct MarkdownEngine: Parser, Renderer {
typealias Parser.Output = MarkdownAST // distinct …
typealias Renderer.Output = HTML // … witnesses
}
Motivation
Consider a markdown engine:
// From package "SyntaxKit":
public protocol Parser {
associatedtype Output // the parse result
func parse(_ text: String) throws -> Output
}
// From a completely unrelated package "RenderKit":
public protocol Renderer {
associatedtype Output // the rendered artifact
func render() -> Output
}
// In *your* app: a Markdown engine that is naturally both.
// But it doesn't compile...
struct MarkdownEngine: Parser, Renderer {
typealias Parser.Output = MarkdownAST // parse(_:) -> MarkdownAST
typealias Renderer.Output = HTML // render() -> HTML
func parse(_ text: String) throws -> MarkdownAST { … }
func render() -> HTML { … }
}
Parser.Output (an AST) and Renderer.Output (rendered HTML) are genuinely different concepts from unrelated packages that happen to choose the same name. The collision is pure coincidence, the two protocols have no relationship to one another, and you own neither package — so "just rename a requirement" is not an option. Note also that only the associated type collides: the value requirements parse(_:) and render() have distinct names and are witnessed normally.
Today this doesn't compile: you'd be forced to witness Output with a single type, breaking one conformance — or rename a requirement you don't control. With the feature, (MarkdownEngine as Parser).Output is MarkdownAST and (MarkdownEngine as Renderer).Output is HTML.
As @Douglas_Gregor noted on Swift GitHub issue SR-3329:
Unfortunately, I do think this is behaving as designed. To make this work without renaming the associated type
Value, we'd need some other language feature to allow the associated type witness forFactory.Valueto be equivalent toDictionary<Key, Value>. This kind of disambiguation has been discussed on the Swift evolution list a few times, but no single proposal has gotten traction yet.
Hopefully we can get some traction, because the witnesses are already separate in the runtime: associated-type witnesses are stored per-conformance, so Parser's witness table has its own Output slot and Renderer's has its own. Allowing two different witnesses is therefore a source-level name-resolution problem, not a new runtime capability, and the implementation here does so without ABI impact or source breakage. (See PR link at top.)
This is explicitly not intended for protocols in a refinement relationship. When one protocol refines another and redeclares an associated type of the same name, that name resolves to a single witness via the existing override rules — and genuinely distinct concepts in such a hierarchy should keep distinct names. This proposal targets only coincidental collisions between independent protocols.
Proposed solution
Update the compiler to allow using dot-syntax to disambiguate typealiases to same-named associated types:
1. Protocol-qualified witness type aliases
Inside a conforming type, a type alias may name the protocol whose associated type it witnesses:
struct MarkdownEngine: Parser, Renderer {
typealias Parser.Output = MarkdownAST
typealias Renderer.Output = HTML
}
These aliases do not conflict with each other (they share a base name by design), and each one binds only its protocol's requirement.
2. Disambiguated member type references
A member type reference can be qualified by the protocol it should be resolved through, written (X as P).Member:
func cache(engine: MarkdownEngine) {
let ast: (MarkdownEngine as Parser).Output = … // MarkdownAST
let html: (MarkdownEngine as Renderer).Output = … // HTML
}
Here, the base is a concrete type, so this works fine. For readability, (X as any P).Member is also accepted and avoids an existential-any warning on the protocol name.
However, a generic parameter constrained to more than one of the protocols collapses the associated types into one type, which the compiler warns about (see Limitations):
// This does NOT disambiguate — the compiler warns, and both are the same type:
func cache<T: Parser & Renderer>(_ engine: T) {
let ast: (T as any Parser).Output = … // ⚠️ collapses to T.Output
let html: (T as any Renderer).Output = … // ⚠️ same type as ast (which one is unspecified)
}
Note that the qualifier must name a protocol; any P is accepted, some P is not.
Fortunately, the intended solution for this is already provided by existing Swift. Simply give each protocol its own generic parameter, each constrained by a different one of the protocols:
// This works:
func cache<P: Parser, R: Renderer>(_ parser: P, _ renderer: R) {
let ast: P.Output = … // P's Parser.Output (e.g. MarkdownAST)
let html: R.Output = … // R's Renderer.Output (e.g. HTML)
}
cache(engine, engine) // P = R = MarkdownEngine
Detailed design
-
Parsing. A type alias declaration accepts an optional protocol qualifier:
typealias P.Name = T. The qualifying protocol may itself be written as a
qualified name (e.g.Mod.P). The qualifier is stored on theTypeAliasDecl;
the alias keeps its base name so that associated-type witness lookup can find
it, but it is excluded from ordinary unqualified name lookup and from
redeclaration checking. The alias may be declared either in the primary type
declaration or in an extension; the same rules apply. -
Witness resolution. When resolving the type witness for protocol
P's
associated type, a protocol-qualified alias namingPis used exclusively;
qualified aliases naming other protocols are ignored for that requirement. -
Member type grammar.
(X as P).Memberis a new type representation. For a
concrete base it resolves toX's type witness forP's associated type.
For an abstract type parameter it forms the dependent member type forP's
associated type (but see Limitations). -
Diagnostics. Referring to a member that the named protocol does not declare,
or naming a non-protocol afteras, is an error. See Limitations for the
abstract-generic warning.
Source compatibility
Additive. The new typealias P.Name syntax is a parse error today, so no existing code uses it. Existing code that names T.Member under a single protocol is unaffected. The behavior is gated behind an experimental feature flag.
ABI compatibility
This proposal is additive and has no effect on ABI. Associated-type witnesses are already stored per-conformance, so distinct witnesses introduce no new mangling or runtime structures. Concrete disambiguation and the split-parameter idiom reuse existing mechanisms.
Limitations and future directions
Abstract disambiguation on a single type parameter
For a generic parameter T: Parser & Renderer, the type system canonicalizes T.Output to a single type: the requirement machine merges same-named associated types of one type parameter into one equivalence class (an emergent consequence of completing the rewrite rules [P].Name => [P:Name] against the conformance rules). As a result, (T as Parser).Output and (T as Renderer).Output denote the same type when written on one type parameter.
Keeping them distinct would require changing generic-signature canonicalization, which feeds name mangling and module serialization—i.e. an ABI-level change—and a prototype that simply dropped the merge violated the rewrite system's confluence invariant. This is left as future work.
Because this case is silent today, the compiler emits a warning when (T as P).Member is written on such a type parameter, recommending the split-parameter idiom:
warning: disambiguation of associated type 'Output' has no effect: 'T'
conforms to both 'Parser' and 'Renderer', whose 'Output' are merged into a
single type parameter; to keep them distinct, constrain each protocol with its
own generic parameter instead
Recommended pattern for generic code
To use both associated types from a single value in generic code, use the split-parameter pattern shown in Disambiguated member type references: give each protocol its own generic parameter and pass the value for both. The two associated types then live on two different type parameters and are never conflated. This requires no new language feature.
Implementation notes (experimental)
The implementation PR passes its new tests, with two known gaps:
- The new syntax is currently parsed only by the C++ parser; the SwiftSyntax
(ASTGen) parser does not yet understand it, so tests pass
-disable-experimental-parser-round-trip. - The two witness aliases currently share a USR in symbol-graph output (the
mangler ignores the qualifier); DocC/indexing would see a collision. Folding
the qualifying protocol into the alias USR is a small follow-up.
Alternatives considered
- Renaming one requirement—the status quo; pushes the awkward name onto
every reader and writer forever, and is unavailable when you own neither
protocol. - Spelling of the disambiguated reference.
(X as P).Memberreuses the
familiar "view this through protocolP" reading ofas. Alternatives such as
T.P.Member,P.Member for T, orT[as: P].Memberwere considered;
(X as P).Memberwas chosen because it parallels the existingascoercion,
reads naturally for concrete and (where supported) abstract bases, and is
straightforward to parse given the parenthesized base. - Full abstract
(T as P).Membersupport—desirable but ABI-scale; see
Limitations.