What do internal imports and MemberImportVisibility change, and how do you stop a dependency leaking through your module?
Predict first
Which of these compile: a client using an extension of a module it did not import; a public API exposing the dependency’s type?
// === Core/Core.swift ===
public struct Money { public var cents: Int; public init(cents: Int) { self.cents = cents } }
extension Int { public var shout: String { "\(self)!" } }
// === Feature/Internal.swift ===
internal import Core
public func label(for amount: Int) -> String { Money(cents: amount).cents.shout }
// === Feature/Plain.swift ===
import Core
public func label(for amount: Int) -> String { Money(cents: amount).cents.shout }
// === Feature/Bad.swift ===
internal import Core
public func price() -> Money { Money(cents: 1) }
// === Feature/Bare.swift ===
import Core
public func price() -> Money { Money(cents: 1) }
// === Client/main.swift ===
import Feature
print(label(for: 5))
print(5.shout)
// === run.sh ===
C="swiftc -swift-version 6"
$C -parse-as-library -emit-module -module-name Core Core/Core.swift -emit-module-path Core.swiftmodule 2>&1 | grep error
mkdir -p m1 m2
$C -parse-as-library -emit-module -module-name Feature Feature/Plain.swift -I . -emit-module-path m1/Feature.swiftmodule 2>&1 | grep error
$C -parse-as-library -emit-module -module-name Feature Feature/Internal.swift -I . -emit-module-path m2/Feature.swiftmodule 2>&1 | grep error
q() { "$@" 2>&1 | grep "error:" | grep -v "emit-module command" | head -1 | sed 's/^[^ ]* error: //' ; }
echo "client, plain import in Feature: $(q $C -typecheck Client/main.swift -I m1 -I . | cut -c1-60)"
echo "client, internal import in Feature: $(q $C -typecheck Client/main.swift -I m2 -I . | cut -c1-60)"
echo "client, internal import + MemberImportVisibility: $(q $C -enable-upcoming-feature MemberImportVisibility -typecheck Client/main.swift -I m2 -I .)"
echo "public API using Core, internal import: $(q $C -parse-as-library -emit-module -module-name F2 Feature/Bad.swift -I . -emit-module-path /dev/null)"
echo "public API using Core, plain import: $(q $C -parse-as-library -emit-module -module-name F3 Feature/Bare.swift -I . -emit-module-path m1/F3.swiftmodule)"
echo "public API using Core, InternalImportsByDefault: $(q $C -enable-upcoming-feature InternalImportsByDefault -parse-as-library -emit-module -module-name F4 Feature/Bare.swift -I . -emit-module-path m1/F4.swiftmodule)"
- the client compiles by default and fails with MemberImportVisibility; a public API exposing the type fails with an internal import (or when imports are internal by default) and compiles with a plain import
- The client always fails to compile
- Internal imports have no effect on public signatures
Mental model. A plain import is leaving the storeroom door open: anything inside can be seen from the shop floor. Internal imports lock it, and member import visibility stops people borrowing tools from rooms they never signed into.
A plain import Core in a Swift 6-mode file is, by default, a public import: declarations of Core may appear in your public API, and your client can use them. Two separate leaks follow. First, you can expose a dependency by accident: a public function returning Money from Core compiles with a plain import (the check), so Core becomes part of your contract. Writing internal import Core (SE-0409 access levels on imports) turns that into a compile error (“function cannot be declared public because its result uses an internal type”), and the upcoming feature InternalImportsByDefault makes every bare import internal so exposure must be written as public import. Second, a client that never imported Core can still call its extension members: in the check 5.shout, defined in Core, compiles in the client that only imports Feature, whether or not Feature imported Core internally, because member lookup sees every loaded module. The upcoming feature MemberImportVisibility (SE-0444) fixes it: the same line then fails with “not available due to missing import of defining module ‘Core’”.
For architecture this makes boundaries checkable by the compiler rather than by convention. In a layered or feature-module codebase: enable both upcoming features (in SwiftPM swiftSettings: [.enableUpcomingFeature("InternalImportsByDefault"), .enableUpcomingFeature("MemberImportVisibility")]), write public import only where a module truly re-exports a type in its API, and use package import for sibling modules in one package. Consequences: code that relied on a transitive import now needs an explicit import line; @_exported import remains the (discouraged) way to re-export a whole module; and the benefits stack with library evolution, because an internal import is not part of the module’s binary interface, so a dependency can change without a rebuild of clients. The check ran with the Swift 6.4 compiler on macOS; the feature flags are the names the compiler accepts in that version, so confirm them on your toolchain before depending on them in CI.
Member lookup leaks across modules unless MemberImportVisibility is on; import access levels control what appears in a module’s API.
Common traps
- Exposing a dependency’s type in a public signature by accident. It silently becomes part of your API; use internal imports so the compiler stops it.
- Relying on an extension from a module you did not import. It works today by leakage and breaks when visibility rules tighten; import what you use.
They might follow up with
- How does this relate to
packageaccess?package importshares a dependency inside one package without making it public. - Does it change what ships? Only the module interface; internal imports keep the dependency out of the public interface.
Related questions
- 15.9.1 Why does adding a defaulted parameter break clients that were already built, and how do you keep the old…
- 15.3.1 What is a feature module, how do you draw module boundaries, and how does access control enforce them?
- 16.1.2 How do internal, package and public work between modules, and what are the classic surprises?
- 16.3.2 Why must the module dependency graph be acyclic, how do you layer it, and what does its shape do to build…
← Why does adding a defaulted parameter break clients that were already… How do you keep an architecture from eroding: dependency rules as… →