Blog Post

Swift's approach to SPI

Illustration: Swift's approach to SPI

Most, if not all, Swift developers are familiar with Swift’s access control system. It enable us to hide the implementation details of our code and design a clean interface (the so-called API) through which the code can be accessed. While this system is pretty powerful, we unfortunately hit its limits if we want to custom tailor our API for different use cases — such as broadening the number of exposed interfaces for internal products while keeping a more refined public API for third-party consumers. Luckily, there’s a solution — the experimental @_spi attribute — which we’ll explore in a bit more detail in this post.

System Programming Interface

System Programming Interface (SPI) is a somewhat uncommon software engineering term, and it refers to a kind of API that’s exposed to just a limited set of consumers and then hidden for others. An example would be Apple’s system frameworks, which have a well-defined stable public API, but also expose a lot of additional functionality that’s intended solely for other Apple frameworks and applications.

Apple is, however, not the only company that has this need. At Nutrient, we also distribute a set of binary iOS frameworks. They include a model-layer framework with PDF APIs, a UI framework, and various optional add-on frameworks. They all have public APIs intended for our customers, but in addition, require internal hooks to facilitate better integration and reuse of helpers. On top of those frameworks, we also built an end-user application — PDF Viewer — in which we use both our private helpers and experimental functionality we’re not ready to expose to our customers just yet.

The @_spi attribute

In Objective-C, an SPI would be defined by creating a private umbrella header. That header would be made available to internal dependencies but not distributed to third parties. This concept could then be made compatible with Swift API consumers with the help of private module maps. There used to be no built-in way to create the same for a pure Swift SPI — until early 2020, when the @_spi attribute was first introduced to the language.

Attribute

The @_spi attribute defines a new syntax, which is used to annotate public declarations with a custom name. This makes the marked API only accessible from the same module and by clients that import the module with the same @_spi name.

As an example, in the code below, we’re making helper(), a public function in PSPDFKit.framework, part of the Internal SPI:

@_spi(Internal) public func helper() {}

To use helper() outside of PSPDFKit.framework, we need to import it with the following:

@_spi(Internal) import PSPDFKit

This is done instead of using import PSPDFKit.

Module interface

To make the above example work, PSPDFKit.framework needs to generate a secondary Swift interface (.private.swiftinterface) in addition to the usual .swiftinterface. This is done by adding the -emit-private-module-interface-path compiler option. This file can be included for internal dependencies but removed when distributing frameworks to third parties. This is a manual post-processing step, but it’s where the inconveniences end for a pure Swift product.

Objective-C compatibility

Swift APIs annotated with the @objc attribute are exposed to Objective-C via a generated -Swift.h header. As there’s currently no distinction between the public and private version of the generated header, the SPIs still end up in the generated header. So, another post-processing step is needed to manually remove those declarations.

Risks

Anyone who’s familiar with Apple’s API naming conventions will immediately recognize the underscore prefix as a problem. Underscored APIs in Swift are considered private, which means there are no guarantees about the stability of their syntax and semantics. Ironically, you need to use the Swift language SPI to be able to model your own SPI.

Consulting the Underscored Attributes Reference, which is the main reference point for @_spi, reveals an obvious and clear warning at the top of the documentation:

WARNING: This information is provided primarily for compiler and standard library developers. Usage of these attributes outside of the Swift monorepo is STRONGLY DISCOURAGED.

However, it’s also clear that this API is already being used by both Apple internally and some third parties, like the popular Stripe framework. While we acknowledge that there’s some risk that this API might change in the future, we’ve received some pretty clear hints that this is generally considered safe and is, right now, the best way to design an SPI in Swift.

Testable as a suboptimal alternative

Swift does have another language-level feature to adjust API access control: the @testable import attribute. This — in combination with building with the testability build setting enabled (the -enable-testing flag) — raises access levels to public. In a sense, @testable can be (ab)used to define an SPI by always compiling with testability enabled and using @testable imports where an SPI is needed.

This avoids usage of experimental language features, but it comes with a major caveat. Since the code needs to be compiled with testability even for production builds, we’re exposing all internal symbols — not just to our SPI consumers, but to anyone who imports the framework with @testable import. This reduces encapsulation and leaves the code open to more (accidental) misuse.

The future of @_spi

Due to the niche nature of SPIs, it seems unlikely that @_spi will become a public feature anytime soon. Talking to Swift language engineers, we found out that they think there’s a better way to describe SPIs as a language feature, but they don’t yet know how exactly that would look, and it isn’t something that’s actively being worked on right now.

All that said, we decided that — right now — this is still the best way to solve our problems, and we’ve accepted the slight risk of potentially needing to refactor the functionality in the future. If the worst happens and the feature is completely removed, we can still fall back to @testable.

FAQ

Here are a few frequently asked questions about Swift and SPI.

What is Swift’s @_spi attribute?

The @_spi attribute in Swift allows you to create internal APIs for specific clients without exposing them publicly.

Is the @_spi feature safe to use in production?

While it’s used by several frameworks, @_spi is experimental and may change, so developers should be prepared for potential future refactoring.

How does @_spi work with Swift and Objective-C frameworks?

For Swift frameworks, you must import the SPI with the specific name. For Objective-C, you’ll need to manually remove SPI entries from the generated header.

What are the risks of using @_spi in my project?

The feature could change or be deprecated, which may require significant changes in the future. It also lacks guarantees of long-term stability.

What’s the alternative to @_spi in Swift?

An alternative is using the @testable import, but it exposes all internal symbols, which can reduce encapsulation and security.

Author
Matej Bukovinski CTO

Matej is a software engineering leader from Slovenia. He began his career freelancing and contributing to open source software. Later, he joined Nutrient, where he played a key role in creating its initial products and teams, eventually taking over as the company’s Chief Technology Officer. Outside of work, Matej enjoys playing tennis, skiing, and traveling.

Related products
Share post
Free trial Ready to get started?
Free trial