What does internal import (SE-0409) change, and why does it matter for API leakage and build times?

5.11.2 · L5 · Interview

Predict first

How many errors does each case give?

// === Dep.swift ===
public struct Dep { public init() {} }
// === U1.swift ===
internal import Dep
public func make() -> Dep { Dep() }
// === U2.swift ===
import Dep
public func make() -> Dep { Dep() }
// === U3.swift ===
internal import Dep
func make() -> Dep { Dep() }
public func ok() -> Int { 1 }
// === run.sh ===
swiftc -swift-version 6 -parse-as-library -emit-module -module-name Dep -o Dep.swiftmodule Dep.swift
for f in U1 U2 U3; do echo "$f: $(swiftc -swift-version 6 -typecheck -parse-as-library -I . $f.swift 2>&1 | grep -q 'error:' && echo error || echo ok)"; done
echo "U2 with InternalImportsByDefault: $(swiftc -swift-version 6 -typecheck -parse-as-library -I . -enable-upcoming-feature InternalImportsByDefault U2.swift 2>&1 | grep -q 'error:' && echo error || echo ok)"
  • only U1 and U2-with-the-feature are errors
  • none is an error
  • U2 is an error
  • U3 is an error

Mental model. A plain import is leaving your tools on the shop counter where customers see them. An internal import keeps them in the back room, and the compiler objects if you hand a customer a tool from there.

Without it, a plain import Dep in any file makes Dep visible to the whole module and, since it is effectively public, lets Dep’s types appear in public API without a diagnostic. An access-level import states the intent: internal import Dep means “my implementation uses Dep”, and the compiler then rejects a public declaration that exposes a Dep type (“function cannot be declared public because its result uses an internal type”). In the run, U1 with internal import and a public function returning Dep is an error, the plain-import version U2 compiles, and U3 compiles when the function using Dep is internal. Turning on the upcoming feature InternalImportsByDefault makes internal the default, so the plain import in U2 becomes the error too.

The benefits are structural: you cannot leak an implementation dependency into your API by accident, a downstream client does not need your dependency in its search paths for correctness, and tooling can see which imports are really API (and so which modules must be rebuilt when they change). Use it in framework code and enable the feature in a migration, one module at a time, since existing code that re-exports dependencies deliberately will need public import (or @_exported if that is the intent). The library-evolution caveats still apply to anything you do expose.

Only the combination of an internal import with a public declaration using the type is rejected.

Common traps

  • Enabling the feature module-wide on a large codebase in one go. Fix one module at a time.
  • Confusing it with @_implementationOnly. The older attribute hid the dependency in the binary; access-level imports are the supported replacement.

They might follow up with

  • What other levels exist? public import, package import, fileprivate import and private import.
  • Does it change build times? It narrows what must rebuild, but I did not measure it.

Related questions

  • 5.7.2 What are upcoming features and experimental features, and how do they help a migration?
  • 5.4.2 What is a .swiftinterface file, and what does it show about @inlinable and @frozen?
  • 1.7.3 What does package access solve?
  • 5.6.2 How does incremental compilation decide what to rebuild, and why does a small edit sometimes rebuild half the…

← How do package access and @_spi differ from public and internal, and…   What is strict memory safety (Swift 6.2), what does the unsafe… →