Skip to main content

Swift Package Traits

Swift Package Traits let you add opt-in features to your packages without dragging in unused dependencies. Learn how to define and use them from scratch.

Swift Package Traits
Photo by Jan Meeus on Unsplash

Introduction

Here is a situation every library author has run into at some point. You write a solid, focused library. Then someone on the team asks, “Can we add Firebase support?” You add it. Then another team says they want CloudKit instead. You add that too. Before long, every consumer of your library is dragging in Firebase, CloudKit, and three other SDKs just to get basic console output., even the teams that never needed any of it 😅

SE-0450 gives us a clean answer to this called Swift Package Traits, shipped in Swift 6.1. Think of traits as build-time feature flags that live right inside your Package.swift. They let you say “pull in Firebase only if the consumer explicitly asks for it” and the toolchain handles the rest.

In this article, we will build a logging library called SwiftLogger from package definition level and use it as our canvas to explore traits from every angle, defining them, conditional compilation, enabling them as a consumer, testing, and running them from the terminal. If that’s sound interesting to you, let’s get into it.

The SwiftLogger

Throughout this article we will work with a logging library that has three modes:

  • Console logging — the default, always available, works everywhere.
  • Firebase logging — syncs log entries to Firebase Crashlytics for remote monitoring.
  • Verbose logging — attaches the file name, function, and line number to every entry, great for debugging.

Without traits, you would either ship three separate packages or bundle everything together and let consumers ignore what they do not need. With traits, consumers get exactly what they ask for, and nothing else.

Quick note: SwiftLogger is a concept demo, not a production-ready logging design. A real logging library would involve structured log levels, thread safety, formatters, output destinations, and more — that is a whole topic on its own. We are keeping it minimal here so the traits concepts stay in focus.

Defining Traits in Package.swift

Let’s start with the Package.swift for SwiftLogger. We will walk through the interesting parts right after:

// swift-tools-version: 6.1
import PackageDescription

let package = Package(
    name: "SwiftLogger",
    platforms: [.macOS(.v13), .iOS(.v16)],
    products: [
        .library(name: "SwiftLogger", targets: ["SwiftLogger"]),
    ],
    // 1.
    traits: [
	    // 2.
        // What you get when you add this package without specifying any traits
        .default(enabledTraits: ["ConsoleLogging"]),

        .init(name: "ConsoleLogging",
              description: "Basic console output, on by default"),

        .init(name: "FirebaseLogging",
              description: "Sync log entries to Firebase Crashlytics"),

        .init(name: "VerboseLogging",
              description: "Attach file, function, and line number to every log entry"),
    ],
    dependencies: [
        .package(
            url: "https://github.com/firebase/firebase-ios-sdk.git",
            from: "11.0.0",
            traits: [
	            // 3.
                // Only fetch this when our FirebaseLogging trait is active
                .init(name: "FirebaseCrashlytics", condition: .when(traits: ["FirebaseLogging"]))
            ]
        ),
    ],
    targets: [
        .target(
            name: "SwiftLogger",
            dependencies: [
                .product(
                    name: "FirebaseCrashlytics",
                    package: "firebase-ios-sdk",
                    condition: .when(traits: ["FirebaseLogging"])
                ),
            ]
        ),
        .testTarget(
            name: "SwiftLoggerTests",
            dependencies: ["SwiftLogger"]
        ),
    ]
)
  1. The traits array is the new piece of the puzzle. Each entry is a Trait — a name, an optional description, and an optional enabledTraits: list. That last field is for trait-chaining. If you write .init(name: "FirebaseLogging", enabledTraits: ["ConsoleLogging"]), enabling FirebaseLogging automatically enables ConsoleLogging too. Our traits here are independent so we leave those empty, but it is a useful tool when one feature logically implies another.
  2. The .default(enabledTraits:) call tells SPM which traits should be on automatically when someone adds the package without specifying anything. Here that is ConsoleLogging, so the logger works immediately without any setup.
  3. The really satisfying bit is down in dependencies. The Firebase iOS SDK is only downloaded and linked when FirebaseLogging is active. A consumer who just wants console output never fetches Firebase at all, not in the dependency graph, not in the build, not anywhere on their machine.

Core Concepts

Before we go further, let’s talk through three ideas that are fundamental to how traits work. They sound a bit abstract at first, but they make total sense once you see them in context.

Additive-Only

Here is the golden rule. Enabling a trait can only add things, never take them away.

Why the rule? Because when your library is used in a larger app, multiple packages in the dependency graph might all request different traits from SwiftLogger. SPM resolves your library exactly once and merges all those requests together. The resulting build has to work for every requester at the same time.

So if you ever designed a SilentMode trait that hid a public log() function, you would be setting yourself up for trouble. The moment another package in the same graph also enables VerboseLogging, both traits are active simultaneously and you have removed an API that something else was relying on.

The mental model that helps me a lot when thing about traits is that “here is some more stuff you can have”, never “here is a different version of the library”. As long as you stick to that framing, you will be fine.

Trait Unification

This is the mechanism that makes the additive rule necessary. After SPM finishes resolving the dependency graph, it calculates the union of all traits requested for each package. If your app wants FirebaseLogging and an analytics library your app uses wants VerboseLogging, what actually gets compiled is SwiftLogger with both traits on:

YourApp   -> SwiftLogger (FirebaseLogging)
Analytics -> SwiftLogger (VerboseLogging)
Resolved  -> SwiftLogger (FirebaseLogging + VerboseLogging)

There is no winner-takes-all. Every requested trait ends up enabled. This is why mutually exclusive traits are a trap, design traits to stack on top of each other, not to cancel each other out.

Namespace Bounds

Every trait’s name is scoped to the package that declared it. SwiftLogger’s FirebaseLogging and SomeAnalyticsTool’s FirebaseLogging are completely separate things. They share a name but never interfere with each other.

There is one side effect of this scoping that trips people up, you cannot use a dependency’s trait name in your own source code. If your app depends on SwiftLogger with FirebaseLogging enabled, writing #if FirebaseLogging inside your app’s own Swift files will not do anything useful. That condition resolves to false because your app never declared a trait by that name, it belongs to SwiftLogger.

The fix is to declare a local trait in your own package and wire it to the dependency’s:

// In YourApp's Package.swift
traits: [
    // Declare the local trait — this is what your own source files can branch on
    .init(name: "EnableFirebaseLogs",
          description: "Activates the Firebase backend in SwiftLogger"),

    // Enable it by default (or skip this and use --traits EnableFirebaseLogs on the CLI)
    .default(enabledTraits: ["EnableFirebaseLogs"]),
],
dependencies: [
    .package(
        url: "https://github.com/myteam/SwiftLogger.git",
        from: "1.0.0",
        traits: [
            .defaults,
            // Turn on SwiftLogger's FirebaseLogging when our local EnableFirebaseLogs is active
            .init(name: "FirebaseLogging", condition: .when(traits: ["EnableFirebaseLogs"]))
        ]
    )
]

Now #if EnableFirebaseLogs works in your app’s source. The key is that the local trait still needs to be enabled through the usual mechanisms, .default(enabledTraits:) in your own manifest, or --traits EnableFirebaseLogs on the CLI. The wiring to the dependency kicks in automatically once the local trait is active.

Conditional Compilation

Back inside SwiftLogger, trait names are injected as compiler conditions automatically. You use them just like #if DEBUG, no extra configuration, no define step

// Sources/SwiftLogger/Logger.swift
import Foundation
#if FirebaseLogging
import FirebaseCrashlytics
#endif

public struct Logger {
    public static func log(
        _ message: String,
        file: String = #file,
        function: String = #function,
        line: Int = #line
    ) {
        let output: String
        #if VerboseLogging
        let location = "\(URL(fileURLWithPath: file).lastPathComponent):\(line) \(function)"
        output = "[\(location)] \(message)"
        #else
        output = message
        #endif

        print(output)

        #if FirebaseLogging
        Crashlytics.crashlytics().log(output)
        #endif
    }
}

And you can nest conditions freely when the logic calls for it

#if FirebaseLogging
    #if VerboseLogging
    // Rich structured log to Crashlytics, with call-site metadata
    #else
    // Plain message to Crashlytics
    #endif
#endif

One alternative worth knowing about if if you are migrating older code that already uses -D MY_FLAG defines, you can map a trait to a custom flag name through swiftSettings:

.target(
    name: "SwiftLogger",
    swiftSettings: [
        .define("FIREBASE_BACKEND_ENABLED", .when(traits: ["FirebaseLogging"])),
    ]
)

Then use #if FIREBASE_BACKEND_ENABLED in your source. For a new package the plain trait name is simpler, but this bridge exists if you need it.

Default Traits and Opting Out

.default(enabledTraits:) is your way of saying “this is the sensible out-of-the-box experience”. For SwiftLogger, that is ConsoleLogging. Add the package, start calling Logger.log(...), and it just works.

When a consumer wants to customise, the syntax is simple. Opt into Firebase on top of the defaults:

.package(
    url: "https://github.com/myteam/SwiftLogger.git",
    from: "1.0.0",
    traits: [.defaults, "FirebaseLogging"]
)

Start from a clean slate with no defaults at all:

.package(
    url: "https://github.com/myteam/SwiftLogger.git",
    from: "1.0.0",
    traits: []
)

The explicit empty array disables everything, including defaults. Note that this is different from simply omitting the traits: parameter, which would apply the package’s default traits. If you want the bare package, the empty array is the right move.

More Use Cases

The logging library is a great illustration, but the pattern applies anywhere you have optional integrations.

Crash reporters and analytics backends. Whether your consumers use Sentry, Bugsnag, Amplitude, or nothing at all, each integration becomes its own trait. Consumers pay only for what they enable, and your package stays lean by default.

Verbose debug tooling. VerboseLogging is effectively a scoped, build-time DEBUG flag. You can hand a QA team an internal build with VerboseLogging on with all the rich call-site metadata without it leaking into the public release binary. Because it is a compile-time condition, there is zero runtime cost when it is off. The verbose code path does not exist in that binary at all.

Platform-specific backends. Traits compose well with platform conditions. Say you want an optional CloudKitLogging trait that only makes sense on Apple platforms:

// In Package.swift — trait declaration
.init(name: "CloudKitLogging", description: "Archive logs to iCloud via CloudKit"),

// In the target's dependencies
.product(
    name: "CloudKitLogTransport",
    package: "cloudkit-transport",
    condition: .when(platforms: [.macOS, .iOS], traits: ["CloudKitLogging"])
)

On a Linux CI server the platform condition does not match, so the dependency never applies. On a developer’s Mac it is there when they want it.

Testing with Traits

This is one of the more satisfying parts of the system. Test targets are trait-aware in exactly the same way as regular targets, the same #if conditions work the same way.

// Tests/SwiftLoggerTests/FirebaseLoggingTests.swift
import Testing
import SwiftLogger

#if FirebaseLogging
@Suite("Firebase Logging")
struct FirebaseLoggingTests {

    @Test("Messages are forwarded to Crashlytics")
    func messagesAreForwarded() async throws {
        Logger.log("hello from test")
        // assert the message was captured by a mock Crashlytics recorder
    }

    @Test("Verbose mode attaches call-site metadata")
    func verboseAttachesCallSite() async throws {
        #if VerboseLogging
        // assert the captured message contains a filename and line number
        #endif
    }
}
#endif

When FirebaseLogging is off, this entire suite is excluded from compilation. No skipped tests, no stubs, no #available workarounds, the tests simply do not exist in that build configuration. Which is exactly the right behaviour.

Quick note while we are here, I wanna set a record straight: Swift Package traits and Swift Testing traits are completely different things. The Swift Testing framework uses the word “trait” for metadata you attach to test functions, things like .bug("issue-123"), .timeLimit(.minutes(1)), or .tags(.networking). Those describe a test at runtime. SPM package traits are build-time feature flags. They happen to share the word “trait” but live in entirely different layers of the toolchain. It is easy to mix them up when you first read about both features, so it is worth being clear on this upfront.

A useful three-tier testing strategy for SwiftLogger:

# Default suite — fast, no credentials needed — run on every commit
swift test

# Full suite with all backends — run nightly or before a release
swift test --enable-all-traits

# Minimum configuration, no defaults — catch regressions in the bare package
swift test --disable-default-traits

CLI Commands

Traits are first-class citizens on the swift command line. All flags work with build, test, and run.

Enable traits by name, comma-separated:

swift build --traits FirebaseLogging
swift build --traits FirebaseLogging,VerboseLogging
swift test  --traits VerboseLogging

Turn on every trait the package defines at once — useful for thorough local testing:

swift build --enable-all-traits
swift test  --enable-all-traits

Strip the defaults to test the bare-minimum package:

swift build --disable-default-traits
swift test  --disable-default-traits

Run an executable with traits active:

swift run --traits VerboseLogging MyLoggerDemo
swift run --traits FirebaseLogging,VerboseLogging MyLoggerDemo

These flags apply to the root package. Traits for dependencies are configured in Package.swift — you do not pass them on the command line.

Things to Watch Out For

Mutually exclusive traits will cause problems. Because of Trait Unification, two traits that logically contradict each other can both end up active when your package is used in a bigger dependency graph with bigger team structure. There is no “one of these wins” mechanism. Design traits to stack, not to conflict. And perhaps, add some guard rails in your agent md file to have an extra pair of eyes to avoid this case.

You cannot read a dependency’s trait from your own source. Writing #if FirebaseLogging in a package that uses SwiftLogger will not reflect that dependency’s state. Trait names are scoped. Map dependency traits to local ones, as shown in the Namespace Bounds section.

Do not remove API behind a trait. Hiding a public method when a trait is off breaks the additive contract and will silently break downstream consumers who have that trait enabled somewhere in the graph.

The 300-trait ceiling. Last time I checked, the current implementation allows a maximum of 300 traits per package, which makes total sense to me. For any normal library this limit is irrelevant, but it is worth knowing if you are designing an extremely granular, pluggable SDK.

Reserved names. You cannot name a trait default or defaults in any casing, those are reserved by the system.

Wrapping Up

Swift Package traits are one of those features where, once you see the problem they solve, you wonder how things ever worked without them. The dependency graph used to be pretty much all-or-nothing. Traits open up a middle ground, a library that is small and focused by default, and grows exactly as much as each consumer asks for.

For SwiftLogger the result is concrete, zero heavy dependencies unless you want them, Firebase available with one line in Package.swift, verbose call-site output that costs nothing when it is off. And all of it is expressed declaratively in the manifest, no wrapper packages, no manual target conditionals, no README footnote telling people to delete a file.

If you want to dig into the full technical spec, the SE-0450 proposal is an unusually readable document and well worth an hour of your time.