Teabyte

App development for Apple platforms and Swift

@_exported import VS public import

Swift 6 introduced a new feature called "Access level imports". With SE-0409 you can attach access level modifiers to your import statements. In the past I was utilising the underscore @_exported attribute in front of some of my imports to make them available to users of my frameworks. While reading through the proposal I wondered: what's the actual difference between these two concepts? This post explores both approaches and clarifies when to use each.

Access Levels

Before going into the core part of the post, a small refresher on access levels. Access levels are typically attached to functions or properties to declare their visibility to other parts of your code in Swift applications. In total, Swift offers six different access level declarations:

  • open
  • public
  • package
  • internal
  • fileprivate
  • private

The list is sorted from least to most restrictive modifiers, e.g. a public access level means any code part annotated with this level can be seen and used from any other part of the code which uses the module the annotated method or property is declared in, whereas private restricts the access to the particular part of the code to the code block it is embedded in.

You can find a pretty extensive explanation about these on the official Swift documentation.

Enabling InternalImportsByDefault

By default, imports in Swift have public visibility. The InternalImportsByDefault setting changes this so that all your imports infer the internal access level, similar to methods and properties. Beware that this could be a breaking change for existing code and you will most likely receive a huge number of compiler errors.

In SPM, add the following to your target:

 targets: [
     .target(
         name: "SPMTestLibrary",
         swiftSettings: [
+            .enableUpcomingFeature("InternalImportsByDefault")
         ]
     ),
 ]

If you are working within an Xcode project, search for "SWIFT_UPCOMING_FEATURE_INTERNAL_IMPORTS_BY_DEFAULT" and set it to YES.

Access Levels on Import Statements

Declaring access levels on imports is as straightforward as with methods or properties. You can prepend the access level to your import statement to apply it.

import Foundation // internal by default if `InternalImportsByDefault` is active
 
private import Foundation
 
public import Foundation

But what's the purpose of defining access levels on import statements? Let's assume you have the following setup with two targets: TargetA and TargetB. TargetA is publicly used throughout your app and offers a conversion method. Within that method you are using a converter method from TargetB.

// TargetB
public struct Converter {
    public func convert(string: String) -> Int {}
}
// TargetA
import TargetB
 
public func conversion(string: String) -> Int {
    return Converter().convert(string: string)
}

With Swift 5 the import TargetB line makes all types of TargetB part of the public interface of TargetA, even though you did not declare any method return types to use any type from TargetB. By utilising access levels on imports you can change that. Either by setting InternalImportsByDefault to YES or by using an internal import TargetB. With that change you can make sure that the import TargetB is treated as an implementation detail that is no longer of interest to anyone importing TargetA. This makes the code intentions cleaner and really reflects what you are doing without unnecessarily "polluting" the public interface of TargetA with things no one needs to know about.

Your IDE will also help you identify errors when working with access levels on imports. If you changed the code to the following, the compiler would complain with "Function cannot be declared public because its parameter uses an internal type".

import TargetB
 
public func convert(string: String, converter: Converter) -> Int {
    return converter.convert(string: string)
}

The compiler will only be happy if you declare the import of TargetB as public:

public import TargetB
 
public func convert(string: String, converter: Converter) -> Int {
    return converter.convert(string: string)
}

@_exported import

@_exported is a Swift attribute that can be used to "re-export" the symbols of the imported module and treat them like they are "yours". As the underscore already implies, we should treat this attribute with caution, since underscore attributes always denote a private attribute which is not meant to be publicly used.

Let's explore what "re-export" really means. Imagine you are implementing a framework that utilises another module's types, e.g., a wrapper around a particular third-party analytics framework, and you want all code that imports your framework to also have access to the types of the third-party framework without needing to declare an import on that particular framework. That's where @_exported comes into play.

/// Module MyAnalytics
@_exported import 3rdPartyAnalytics
 
public func track(event: 3rdPartyAnalytics.Event) { }

Then any other part of the code that uses the track(event:) method imports only MyAnalytics and does not need to declare an import 3rdPartyAnalytics additionally. It simply hides the necessity of an additional import and acts as a convenient way to reduce the knowledge that's necessary to use the MyAnalytics module.

import MyAnalytics // Automatically also imports "3rdPartyAnalytics"
 
track(event: 3rdPartyAnalytics.Event("Button Clicked"))

Compare this to using public import instead:

/// Module MyAnalytics
public import 3rdPartyAnalytics
 
public func track(event: 3rdPartyAnalytics.Event) { }

With public import, consumers would need to explicitly import the dependency themselves:

import MyAnalytics
import 3rdPartyAnalytics // Required! Types are not re-exported
 
track(event: 3rdPartyAnalytics.Event("Button Clicked"))

@_exported import is only necessary once in your whole module. Therefore it is advisable to have a dedicated single file where you re-export all types from other libraries for your module.

Reexports.swift
@_exported import 3rdPartyAnalytics

public import VS @_exported import

Now that we have taken a look at access levels and the private @_exported import method, let's define the differences between the two.

Access levels on imports are very similar to access levels on methods and properties. They declare an intention and transport an idea of access to imports. By defining access levels on imports you are declaring which imports should be part of your module's public interface. If you declare a public import you are saying "Hey, I want to use the types of this module and you have to import them as well in order to work with my code".

This is a very different intention than @_exported import which declares that the types you want to use are not "your own". You will treat the types as if they were coming from your own module. This is particularly helpful if you want to simplify the import statements for your consumers. I used it very often when wrapping third-party libraries or when working on SDK levels where it was necessary to combine multiple "sub" modules into a single "import" statement for consumers. Even though its a private attribute, we can read some documentation about it here.

Here's a quick comparison:

Aspectpublic import@_exported import
PurposeDeclares dependency as part of public APIRe-exports symbols as if they were your own
Consumer must import dependencyYesNo
Official supportYes (SE-0409)Unofficial (underscore attribute)

Overall we can say that access levels on imports and @_exported import are used for different purposes. Access levels are used to declare an intent on how imports are treated for consumers, while @_exported is used to transparently expose another module's types as your own.

Conclusion

In this post we took a look at how access levels on imports relate to the @_exported attribute and found out that they do different things. This was not clear to me in the beginning when I started to look into this topic. I hope this write up also made it clearer to you.

When in doubt, prefer public import as it's officially supported. Reserve @_exported for SDK umbrella modules where simplifying imports for your consumers is critical.

Please feel free to reach out to me if you find any mistakes, or have questions or suggestions. You can find ways to contact me on my About page.

See you next time!

Appendix: Enabling Access Level Imports in Swift 5 Mode

If you are using Swift 6, access level imports are enabled by default. However, if you are still using Swift 5 mode, you need to enable the feature explicitly.

In SPM, add the following to your target:

targets: [
    .target(
        name: "SPMTestLibrary",
        swiftSettings: [
+            .enableExperimentalFeature("AccessLevelOnImport")
        ]
    ),
]

If you are working within an Xcode project, search for "SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY" and set it to YES.