Swift 6: Access level on import statements
Helm Pro yearly subscribers now get a 30% discount on RocketSim thanks to contingent pricing on the App Store.
The SE-0409 proposal introduced the ability to mark import declarations with any of Swift’s available access levels to limit the types or interfaces that imported symbols can be used in. Thanks to these changes, dependencies can now be marked as being visible to the current source file (private
or fileprivate
), module (internal
), package (package
), or all clients (public
).
This proposal introduces changes behind two feature flags which will become on by default in Swift 6:
AccessLevelOnImport
: An experimental feature flag already available that allows developers to mark import declarations with an access level.InternalImportsByDefault
: An upcoming feature flag that is not yet available and changes the implicit access level for import statements frompublic
tointernal
, like Swift 6 will do.
This is a great addition to the language that I have personally longed for a while as it allows developers to better hide implementation details and enforce separation of concerns. Not only that but it also limits the amount of dependencies imported by a package’s clients to the ones marked as public
given the right conditions are met, leading to shorter compile times.
An example
Let’s say we have created a Services
Swift Package that defines a FeedService
target. This target’s job is to fetch a feed of items to display in an app. In turn, FeedService
depends on another target called FeedDTO
that defines a set of autogenerated Decodable
models matching the data structure of an API:
// swift-tools-version: 5.10
import PackageDescription
let package = Package(
name: "Services",
platforms: [.iOS(.v13), .macOS(.v10_15)],
products: [
.library(
name: "FeedService",
targets: ["FeedService"]
),
],
targets: [
.target(
name: "FeedService",
dependencies: ["FeedDTO"]
),
.target(
name: "FeedDTO"
)
]
)
The code for the FeedDTO
target is very simple and is autogenerated based on an OpenAPI specification:
import Foundation
public struct Feed: Decodable {
let items: [Item]
public struct Item: Decodable {
let title: String
let image: URL
let body: String
}
}
The FeedService
target is not much more complex and contains a protocol that defines the service’s interface for clients to use. The implementation of this protocol is also part of the FeedService
target but is not relevant for this example:
import FeedDTO
public protocol FeedService {
func fetch() -> Feed
}
As you can see, we are including the Feed
model from the FeedDTO
target in the public interface of our services. As all import declarations are implicitly public
in Swift 5 and there is no way to change this behavior, the code above compiles without any issues. Despite this, the architecture is far from ideal, we are allowed to expose implementation details and we have no way to make the compiler prevent this leak.
If we notice this issue and want to fix it, we can remove the Feed
model from the public interface and create a domain model that will be part of the public interface instead. The actual implementation of the service will be responsible for converting the FeedDTO.Feed
model to the domain model.
import Foundation
import FeedDTO
public struct Feed {
let items: [Item]
public struct Item {
let title: String
let image: URL
let body: String
}
}
public protocol FeedService {
func fetch() -> Feed
}
While the code above is a step in the right direction, there is nothing in the code explicitly stating that the FeedDTO
module’s usages in this file are an implementation detail and should not be part of the module’s public interface. This is where Swift 6’s feature comes in handy.
Enabling the AccessLevelOnImport
experimental flag
Let’s see how we can make the code from the previous section more explicit and guard against future changes that might expose implementation details in this file by adding an access level to the import statement.
Before we do that, since this feature is still behind an experimental flag, we need to enable it in our Swift Package:
// swift-tools-version: 5.10
import PackageDescription
let package = Package(
name: "FeedService",
platforms: [.iOS(.v13), .macOS(.v10_15)],
products: [
.library(
name: "FeedService",
targets: ["FeedService"]
),
],
targets: [
.target(
name: "FeedService",
dependencies: ["FeedDTO"],
swiftSettings: [
.enableExperimentalFeature("AccessLevelOnImport")
]
),
.target(name: "FeedDTO")
]
)
If you are using an Xcode project, you can enable the feature by adding the
-enable-experimental-feature AccessLevelOnImport
flag to your target’sOTHER_SWIFT_FLAGS
build setting.
Now that we have enabled the feature, we can add an access level to the import statement in the FeedService.swift
file:
import Foundation
private import FeedDTO
public struct Feed {
let items: [Item]
public struct Item {
let title: String
let image: URL
let body: String
}
}
public protocol FeedService {
func fetch() -> Feed
}
With this change, if we were to use the FeedDTO
in the module’s public interface again, the compiler would throw an error. This is a great way to enforce separation of concerns and hide implementation details from the module’s clients:
Note that you can use different access levels for the same dependency across your target. When it comes to performing optimizations and deciding whether to bring the dependency to the consumer of the module, the build system will take the least restrictive access level into account.
⚠️ Breaking changes
There is a big breaking change that comes with the changes introduced by SE-0409: the default access level for import statements will change from public
to internal
in Swift 6. This means that if you are including symbols from a dependency in your module’s public interfaces, you will need to explicitly mark the import statement as public
to avoid compilation errors.
There is a second feature flag that you will soon be able to enable on main
branches of the Swift toolchain called InternalImportsByDefault
to test the new behavior. This is how you will be able to enable it in your Swift Package when it ships:
// swift-tools-version: 5.10
import PackageDescription
let package = Package(
name: "FeedService",
platforms: [.iOS(.v13), .macOS(.v10_15)],
products: [
.library(
name: "FeedService",
targets: ["FeedService"]
),
],
targets: [
.target(
name: "FeedService",
dependencies: ["FeedDTO"],
swiftSettings: [
.enableExperimentalFeature("AccessLevelOnImport"),
.enableUpcomingFeature("InternalImportsByDefault")
]
),
.target(name: "FeedDTO")
]
)
If you are using an Xcode project, you can enable the feature by adding the
-enable-upcoming-feature InternalImportsByDefault
flag to your target’sOTHER_SWIFT_FLAGS
build setting.
Adopting these changes
The best practice when it comes to adopting these new changes is to start by enabling the AccessLevelOnImport
feature flag in your Swift Package and start by adding the most restrictive access level to all your import statements and let the compiler tell you where you might need to make changes.
Here’s a small script that does this for you:
#!/usr/bin/swift
private import Foundation
let fileManager = FileManager.default
let currentDirectory = fileManager.currentDirectoryPath
let swiftFiles = fileManager.enumerator(atPath: currentDirectory)?
.compactMap { $0 as? String }
.filter { $0.hasSuffix(".swift") }
for file in swiftFiles ?? [] {
let filePath = "\(currentDirectory)/\(file)"
guard let content = try? String(contentsOfFile: filePath) else {
continue
}
let updatedContent = content
.replacingOccurrences(of: #"import (\w+)"#, with: "private import $1", options: .regularExpression)
try? updatedContent.write(toFile: filePath, atomically: true, encoding: .utf8)
}
If you are happy about your public interfaces and what they expose or if you see that when you turn on the InternalImportsByDefault
upcoming feature flag you have a lot of compilation errors you don’t want to fix right away, you can modify the script above to add the public
access level to all import statements instead.