How do resources and localisation work inside a module: Bundle.module, process versus copy, and strings tables?

16.5.1 · L3 · Interview

Predict first

A target declares a processed resource and reads it through Bundle.module. What does it print, and is that the main bundle?

// === Package.swift ===
// swift-tools-version: 6.0
import PackageDescription
let package = Package(name: "Res", targets: [.executableTarget(name: "T", resources: [.process("Resources")])])
// === Sources/T/Resources/greeting.txt ===
hello from resource
// === Sources/T/main.swift ===
import Foundation
let url = Bundle.module.url(forResource: "greeting", withExtension: "txt")!
print(try String(contentsOf: url, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines))
print(Bundle.module.bundleURL.lastPathComponent, Bundle.main.bundleURL.lastPathComponent == Bundle.module.bundleURL.lastPathComponent)
  • hello from resource, then Res_T.bundle false
  • hello from resource, then Res_T.bundle true
  • It fails to find the file

Predict first

A target declares no resources and still uses Bundle.module:

// === Package.swift ===
// swift-tools-version: 6.0
import PackageDescription
let package = Package(name: "Res", targets: [.executableTarget(name: "T")])
// === Sources/T/main.swift ===
import Foundation
print(Bundle.module.bundleURL.lastPathComponent)
  • It compiles
  • It does not compile: Bundle has no member module

Mental model. Each module carries its own luggage. Bundle.module is the tag on its suitcase; looking in the main bag (Bundle.main) for it finds only the app’s things.

Inside an app target, resources live in the main bundle (Bundle.main). A module in its own package has its own resource bundle, and code must look there: Bundle.module, an accessor that SwiftPM generates only if the target declares resources (resources: [.process("Resources")]). Without that declaration, Bundle.module does not exist and the code fails to compile (“type 'Bundle' has no member 'module'”). The bundle is a separate directory named <Package>_<Target>.bundle placed next to the executable (and inside the app for Xcode builds), which is why a lookup like Bundle.module.url(forResource: "greeting", withExtension: "txt") finds the file while Bundle.main would not (Bundle.main and Bundle.module are different bundles in a package). Two rules: .process(path) lets the build system transform resources for the platform (compile asset catalogs and storyboards, optimise images, convert strings files, handle localised .lproj folders), while .copy(path) copies a folder verbatim, preserving its structure (use it for data you read by path). Resources can be declared per target; headers and docs are excluded with exclude.

Localisation in modules follows the same idea. Declare defaultLocalization: "en" in the package, put strings in en.lproj/Localizable.strings or a String Catalog (.xcstrings), and look them up with the module’s bundle: String(localized: "welcome", bundle: .module) (or Text("welcome", bundle: .module) in SwiftUI). Forgetting bundle: .module is the common bug: the lookup goes to the app’s bundle, finds nothing, and shows the key. Also set up string tables deliberately (one per feature, named, to avoid key clashes), provide comments for translators, use plural and variation rules in the catalog rather than string concatenation, and test with pseudo-languages (double-length, right-to-left). Images and colours in modules use Image("name", bundle: .module) and a module-local asset catalogue. Watch the costs: every resource bundle adds files to the app, a Bundle lookup is a file-system access (cache the result of expensive ones), and shared assets duplicated across modules inflate the app, so put shared design assets in a single design-system module. Do not reach into another module’s bundle by name; expose a small API instead.

The generated accessor points at the module’s own bundle, Res_T.bundle, which is a different bundle from the main one.

SwiftPM only generates the accessor for targets that declare resources.

Common traps

  • Using Bundle.main inside a module. The resources live in the module’s bundle; use Bundle.module.
  • Localised strings without bundle: .module. The lookup uses the app’s bundle and shows the key instead of the translation.

They might follow up with

  • When do you use .copy rather than .process? For files you read by their exact path or whose structure must be preserved.
  • Why does Bundle.module sometimes not compile? The target declares no resources, so SwiftPM does not generate the accessor.

Related questions

  • 16.5.2 How do you use feature flags: compile-time versus runtime, ownership, and modularisation by team?
  • 16.1.2 How do internal, package and public work between modules, and what are the classic surprises?

How do you use feature flags: compile-time versus runtime, ownership… →