Module Selectors (`::`): Disambiguating Types Across Swift Modules

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.

Wireframe diagram showing two modules exporting colliding type nodes converging through a module-selector gate into a single resolved type target.

The Verdict

  • High value at module boundaries, near-zero in a single-target app. When two imported modules export the same model name, Module::Type removes 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 a private or unimported member.
  • A toolchain floor comes with it. :: is new syntax; older compilers reject it outright. Package authors who vend .swiftinterface files or mark APIs @inlinable must stage the uptake.
  • The hidden cost is readability, not performance. ABI mangling already separates the two Transaction symbols; misqualifying confuses readers, not the linker. Reserve the selector for real collisions, and reach for a typealias where 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.

Ready for more depth?

Master these concepts with our structured technical roadmap.

View Roadmap