One extension on another module's type makes every user of that type recompile on any declaration change in your module (incremental imports)

We split a feature out of a ~1,200-file iOS app target into a local SwiftPM package expecting the usual payoff: edits inside the package shouldn't recompile the app. Body edits did behave (0 app files). But adding any declaration inside the package — a private func, an unreferenced struct, anything that changes the .swiftmodule — recompiled 105 app files, almost none of which import the package's modules. It took a few days of probe builds to find out why, and since we couldn't find this written up anywhere, here's what we learned. Names are anonymized; numbers are real (Xcode 26.6, macOS 26.6.2, M1 Pro, warm DerivedData, counts = unique SwiftCompile lines in the app target).

The setup

  • The app target imports a thin facade FeatureA. The module we edit, FeatureAUI, is imported by exactly 2 app files.
  • FeatureAUI contained extension Media { ... } — Media is a type from a shared core package used all over the app — and a file-scope private typealias Shelf = CoreDomain.Shelf added for readability.

What we measured

Probe = append one unreferenced declaration to a file in the module, rebuild, count app files recompiled. Remove one thing at a time:

State of FeatureAUI App files recompiled per declaration edit
as shipped (extension Media + private typealias Shelf) 105
extension Media removed (methods moved to static funcs on a module-owned enum) 71
private typealias Shelf also removed (uses qualified as CoreDomain.Shelf) 2 (the two importers)
clean state + one private extension Media { var x: Int { 0 } } added back 66

Control, in a module nothing in the app imports: 0 → 74 after adding extension Media { var x: Int { 1 } }; also 74 for an empty conformance extension Media: LocalProto {}.

The 105/71 files are home and detail screens, repositories, use cases — files that use Media or Shelf and never import anything from FeatureA.

Our reading of the mechanism (please correct)

As far as we can tell from docs/DependencyAnalysis.md and swiftlang/swift#92617: cross-module incremental builds ("incremental imports") represent an imported module as a single sourceFileProvide node whose fingerprint covers the whole module interface. A file that extends Media makes the module a provider of Media's members (the "empty member name" provides entry), and a file-scope typealias Shelf makes it a provider of the top-level name Shelf. Every app file that uses Media members or the name Shelf therefore has an arc to this module. When any declaration changes, the module fingerprint changes, and the tracer follows every arc without checking per-type fingerprints (type fingerprints only cover body tokens). Body edits leave the .swiftmodule byte-identical, so nothing fires. #92617 (filed last week) describes this for files that import the module directly; our case seems to be the same thing reached through an extension or typealias instead of an import.

Things that did NOT help

  • private / fileprivate on the extension: same counts. Access control is a type-checker concept; the dependency graph doesn't seem to record it.
  • -enable-upcoming-feature MemberImportVisibility (SE-0444) on the app target, after adding the ~490 imports it demanded: extension Media probe 64 → 64, typealias Shelf probe 69 → 69. Narrowing name-lookup visibility doesn't touch the arcs either.
  • internal import FeatureAUI in the facade: unchanged.

What it costs at scale

A second package, FeatureB, has 21 extensions on types it doesn't own — 14 on String/URL/UIImage/UIViewController/SwiftUI View, 7 on in-house types. One declaration edit there recompiles 1,004 of 1,171 app files. Median of 3 warm builds:

Edit Wall time App files
no-op 19.3 s 0
declaration added in the app target itself 38.9 s 2
body edit in FeatureB 28.4 s 0
declaration added in FeatureB 70.7 s 1,004

So a declaration edit in that "isolated" module is ~32 s slower than editing the monolith directly. System-type extensions are the worst because everything uses String and URL.

What we do about it

Rule for feature packages: don't extension a type you don't own (system types included), and don't re-declare an external name with a top-level typealias. Helpers become static funcs on a module-owned type; name clashes are handled by qualification (CoreDomain.Shelf). A grep for extension <ExternalType> / same-name typealias finds candidates; a probe build confirms. Extensions of the package's own types, and the app target itself, are exempt (nobody imports the app).

The probe is cheap and worth running after any module extraction: add struct Probe1 {} to the module, build, count SwiftCompile … in target 'App'. If the number is larger than the number of app files importing the module, something in the module is providing a name the app uses.

Questions for people who know the driver better than we do

  1. Are these results consistent with the module-level invalidation described in #92617, or could a different dependency path explain them? In particular, the files that recompile never import the declaring module — only the type it extends.
  2. Why does a private extension or a file-scope private typealias register the module as a provider for files that don't import it? If this is the expected conservatism of incremental imports, is the module-level (rather than file-level) granularity inherent to the design, or an implementation limit that could be narrowed?
  3. Would the header-covering fingerprints suggested in #92617 also cover these extension and typealias cases? Top-level typealiases seem to have no fingerprint of their own, so we'd guess that arc stays. Is there an existing compiler option, or a narrower workaround than "don't extend types you don't own", that we should test?

More detail on the measurements is in our comment on swiftlang/swift#92617.

2 Likes