Module Selectors (`::`): Disambiguating Types Across Swift Modules
Module Selectors (::): Disambiguating Types Across Swift Modules
$ swiftc --module-name CheckoutFlow main.swift
main.swift:23:19: error: 'Transaction' is ambiguous for type lookup in this context
final let tx: Transaction = repository.fetchLatest()
^
Found this candidate: Accounts.Transaction (Accounts/Models.swift:5:18)
Found this candidate: Ledger.Transaction (Ledger/Models.swift:8:18)
Swift module selectors — SE-0491, implemented in the Swift 6.3 toolchain and surfaced in the Swift 6.4 release notes — add a ModuleName:: prefix that pins a type reference to the module that owns it. When two modules export the same model type, the ambiguous type error above is the symptom, and the fix is now to write Ledger::Transaction instead of a shim alias or a renamed import.
The Verdict
- High value at module boundaries, near-zero in a single-target app. When two imported modules export the same model name,
Module::Typeremoves the alias and shim tax. If you never import a colliding name, you gain nothing from it. - It disambiguates; it does not grant access. A selector only filters the candidates lookup would otherwise consider. Access control and import visibility still apply —
::cannot reach aprivateor unimported member. - A toolchain floor comes with it.
::is new syntax; older compilers reject it outright. Package authors who vend.swiftinterfacefiles or mark APIs@inlinablemust stage the uptake. - The hidden cost is readability, not performance. ABI mangling already separates the two
Transactionsymbols; misqualifying confuses readers, not the linker. Reserve the selector for real collisions, and reach for atypealiaswhere a single app-facing name is the contract.
Two Modules, One Transaction
The scenario is common in a growing monorepo. A CheckoutFlow target depends on Accounts and Ledger; both ship a Codable model named Transaction. Each is legitimate — the ledger’s version carries posting entries, the account’s carries a settlement state — and neither module will rename its public API because their other dependents spell the name unqualified.
Left alone, Swift reports the collision at every use site, and the resolution historically lived outside the type syntax:
// Module: CheckoutFlow — main.swift
import Accounts // exports Transaction: Identifiable, Hashable, Codable
import Ledger // exports Transaction: Identifiable, Codable
final class OrderRepository {
// This compiled for a year. Ledger 2.0 added its own Transaction and
// every unqualified use in this file went ambiguous at once.
func latest() -> Transaction? { // error: 'Transaction' is ambiguous for type lookup
try? JSONDecoder().decode(Ledger.Transaction.self, from: payload)
}
}
The module-qualified Ledger.Transaction above works only as long as nothing shadows the name Ledger in this scope. The moment a type, macro, or generic parameter named Ledger enters the file, qualified lookup silently targets the wrong thing — which is precisely the class of failure that pushed the proposal forward.
The Workaround Path, And Why It Taxed
Before SE-0491, teams in this position had three options, each with a measurable cost.
1. A shim typealias file — one alias per collision, with the collision mapped each time a dependency lands:
// Module: CheckoutFlow — TypeAliases.swift
// The classic shim. It compiles, but every alias documents *what* the type is,
// never *where* it came from — so readers still guess when a dependency drifts.
import Accounts
import Ledger
typealias LedgerTransaction = Ledger.Transaction
typealias AccountTransaction = Accounts.Transaction
The alias file grows linearly with the collision surface, and it is a review hazard: each new dependency requires re-auditing every alias for a silent rename.
2. Module aliasing (SE-0339) at the package level — renames the module at build time, which is heavier than it sounds:
// Package.swift
.target(
name: "CheckoutFlow",
dependencies: [
.product(name: "Accounts", package: "accounts-kit"),
.product(name: "Ledger", package: "ledger-kit",
moduleAliases: ["Ledger": "LedgerKitAccess"]),
]
)
The rename propagates into mangled symbol names, .swiftmodule/.swiftinterface content, and debug symbols. Any code path that derives a module name at runtime — lookup-by-string, Objective-C interop surfaces — needs the same rename applied, and two clients of ledger-kit cannot alias it to the same name in one dependency graph.
3. Narrow import struct pulls in only one symbol per file:
import struct Ledger.Transaction
// Rest of the file sees only *this* Transaction; the rest of Ledger is hidden.
final let tx = Transaction(date: now, postings: entries)
It is precise, but it hides every other symbol in Ledger, so files that need both Ledger.Transaction and Ledger.Entry end up stacking imports and re-fighting the same collision in each file.
:: — The Module Selector
Module selectors, per SE-0491, appear wherever an identifier references an existing declaration. The lookup starts at the top level of the named module, skipping any enclosing scopes that might shadow the name:
// Module: CheckoutFlow — main.swift
import Accounts
import Ledger
final class OrderRepository {
// The owner is part of the type now. This compiles regardless of what
// else is named `Ledger` or `Transaction` deeper in this scope.
func latest() -> Ledger::Transaction? {
try? JSONDecoder().decode(Ledger::Transaction.self, from: payload)
}
}
The selector also resolves the extension-member ambiguity that ordinary qualification cannot — two modules can attach a nested type of the same name to a shared base type, and their ABI symbols are already distinct:
// Module: Analytics
extension User {
public struct Profile {
public let components: [String]
}
}
// Module: ProfileKit
extension User {
public struct Profile {
public let entries: [String: String]
}
}
// Module: App
import Analytics
import ProfileKit
// `User.` finds the base type; `Analytics::Profile` selects the extension member.
func decorate(_ snapshot: User.Analytics::Profile) { ... }
Two areas that were previously not even syntactically expressible now take a module qualifier. Macro expansions, whose grammar had no slot for a module name, can be pinned:
let text = #TestingMacros::stringify("payload")
And an import declaration can narrow to a single qualified symbol:
import struct Ledger::Transaction // narrow import, self-documenting at the file head
Where The Selector Stops
The boundary cases matter more to adoption than the happy path.
A module selector is a lookup tool, not an access tool: Ledger::internalHelper still fails, because the declaration is not visible outside its module, and a missing or narrow import hides even public symbols. Member types of a generic parameter cannot take a qualifier — the dependent member refers transparently to whatever the protocol conformance binds, so a selector would be meaningless there. And a selector is invalid on the name of a new declaration:
struct CheckoutFlow::Cart { } // error: selector on new declaration
func drain<T: Identifiable>(_ t: T) where T.Swift::ID == Never { } // error: dependent member
Two lexing rules shape formatting. :: may be separated from the module name by whitespace but not from the identifier it qualifies — so a long module name breaks as SuperLongModuleName\n ::symbol(), not SuperLongModuleName::\n symbol(). And because older compilers cannot parse the token at all, adopting it raises the package’s tools version; anyone shipping ABI-stable libraries needs to decide whether .swiftinterface output may use the syntax.
The Hidden Cost And The Adoption Cut
For monorepos with colliding model names, module selectors are the cleanest tool in the box: the owning module is stated by the compiler-documented type reference rather than inferred from a shim file, and the ABI was never the problem — mangled names were always unique. The trade-off shows up in the opposite direction. A codebase that sprinkles Module:: over every type - a “the remit is worth it” reflex - produces signatures where the selector is pure signal duplication. The unqualified name is already resolvable there; adding the owner adds reading cost and diff noise, and it hardcodes a module boundary into files that may later be lifted into a shared target.
The disciplined line: use :: exactly where the compiler raises an ambiguous-type diagnostic, or where a module-name shadow would silently misroute a qualified lookup — at module boundaries in a monorepo, in extension-member collisions, and on macro expansions when two packages export the same macro. Trade it back for a typealias where one stable, app-facing name is the contract (the alias names the concept; the selector names the source). Near-zero value for single-target apps is the correct reading, not a deficiency: the feature fixes a problem those apps do not have.
Internal Links
- Module Interfaces and Incremental Build Performance — name qualification is a print-level problem in module interfaces; SE-0491 is the language-level fix the compiler needs to emit those files correctly.
- Structuring XCFrameworks for Cross-Platform Release — modularization decisions that produce colliding model names across binaries, and the boundary discipline that keeps them manageable.
- withTaskCancellationShield: Structured Cleanup Under Cancellation — another Swift 6.4 addition that pays off specifically at module and task boundaries rather than pervasively.
External Links
- SE-0491: Module Selectors for Name Disambiguation — the accepted proposal: motivation, grammar, and the shadowing cases that motivated the
::spelling. - Swift 6.4 Released — official release notes positioning module selectors for multi-library conflicts.
- Module Aliasing (SE-0339) — the earlier package-level workaround and where it still applies.
- Organizing Your Code with Local Packages — Apple Developer Documentation on modularizing an app and the collision surface modules introduce.
- Building Your Project with Explicit Module Dependencies — Apple Developer Documentation on how the build system resolves and schedules module dependencies.