Swift 6: Access level on import statements

Sponsored
RevenueCat logo
Develop with RocketSim, Ship with Helm.

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 from public to internal, 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:

Package.swift
// 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:

FeedDTO.swift
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:

FeedService.swift
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.

FeedService.swift
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:

Package.swift
// 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’s OTHER_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:

Package.swift
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:

Package.swift
// 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’s OTHER_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:

replace-imports.swift
#!/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.