Skip to content

Swift Package Manager Traits: Feature Flags for Your Dependencies

Published: 5 min read
Edit on GitHub

Swift 6.1 introduces package traits - a way to configure packages with optional features and conditional dependencies. SE-0450 brings feature flags to SwiftPM.

The Problem

Say you’re building a networking library. Some users want logging, others don’t. Some need async/await support, others are stuck on older deployment targets. Before traits, your options were:

All of these have tradeoffs. Other package managers like Cargo have had this capability. Now SwiftPM does too.

Defining Traits

Traits live in your Package.swift. Here’s a library with optional logging:

// swift-tools-version: 6.1
import PackageDescription

let package = Package(
    name: "NetworkKit",
    products: [
        .library(name: "NetworkKit", targets: ["NetworkKit"]),
    ],
    traits: [
        .default(enabledTraits: ["Logging"]),
        .trait(name: "Logging", description: "Enable debug logging"),
        .trait(name: "Metrics", description: "Enable performance metrics"),
    ],
    targets: [
        .target(name: "NetworkKit"),
    ]
)

Three things happening here:

  1. .default(enabledTraits:) - These traits are on unless the consumer opts out
  2. .trait(name:description:) - Define available traits
  3. Traits can enable other traits via enabledTraits: ["OtherTrait"]

Conditional Compilation

In your Swift code, check traits with #if:

public struct NetworkClient {
    public func fetch(_ url: URL) async throws -> Data {
        #if Logging
        print("[NetworkKit] Fetching \(url)")
        #endif
        
        let (data, _) = try await URLSession.shared.data(from: url)
        
        #if Metrics
        MetricsCollector.shared.record(bytes: data.count)
        #endif
        
        return data
    }
}

When Logging is disabled, that code doesn’t exist in the binary. Zero overhead.

Optional Dependencies

Traits really shine for optional dependencies. Want to support both swift-log and plain print?

let package = Package(
    name: "NetworkKit",
    products: [
        .library(name: "NetworkKit", targets: ["NetworkKit"]),
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"),
    ],
    traits: [
        .trait(name: "SwiftLog", description: "Use swift-log instead of print"),
    ],
    targets: [
        .target(
            name: "NetworkKit",
            dependencies: [
                .product(
                    name: "Logging",
                    package: "swift-log",
                    condition: .when(traits: ["SwiftLog"])
                ),
            ]
        ),
    ]
)

The swift-log dependency is declared normally, but the target only links it when SwiftLog is enabled. In your code:

#if SwiftLog
import Logging
let logger = Logger(label: "NetworkKit")
#endif

public struct NetworkClient {
    public func fetch(_ url: URL) async throws -> Data {
        #if SwiftLog
        logger.info("Fetching \(url)")
        #else
        print("[NetworkKit] Fetching \(url)")
        #endif
        
        let (data, _) = try await URLSession.shared.data(from: url)
        return data
    }
}

Consuming Packages with Traits

As a consumer, you control which traits are active:

dependencies: [
    // Use defaults
    .package(url: "https://github.com/example/NetworkKit", from: "1.0.0"),
    
    // Disable defaults, enable specific traits
    .package(
        url: "https://github.com/example/NetworkKit",
        from: "1.0.0",
        traits: [
            "Metrics",
        ]
    ),
    
    // Keep defaults and add more
    .package(
        url: "https://github.com/example/NetworkKit",
        from: "1.0.0",
        traits: [
            .defaults,
            "Metrics",
        ]
    ),
]

Conditional Trait Enabling

You can enable a dependency’s trait based on your own local traits:

let package = Package(
    name: "MyApp",
    traits: [
        .default(enabledTraits: ["MyBasicApp"]),
        .trait(name: "MyBasicApp", description: "Basic app"),
        .trait(name: "MyFullApp", description: "Full app", enabledTraits: ["UseLogging"]),
        .trait(name: "UseLogging"),
    ],
    dependencies: [
        .package(
            path: "../NetworkKit",
            traits: [
                .trait(name: "Logging", condition: .when(traits: ["UseLogging"])),
            ]
        ),
    ],
    targets: [
        .executableTarget(
            name: "MyApp",
            dependencies: [
                .product(name: "NetworkKit", package: "NetworkKit"),
            ]
        ),
    ]
)

When MyFullApp is enabled, it enables UseLogging, which in turn enables Logging on the NetworkKit dependency.

CLI Flags

For testing, you can override traits from the command line:

# Build with specific traits
swift build --traits Logging,Metrics

# Enable everything
swift build --enable-all-traits

# Disable defaults
swift build --disable-default-traits

# Test with different configurations
swift test --traits Metrics
swift test --disable-default-traits

This is great for CI - test all trait combinations to catch issues early.

Traits Can Compose

A trait can automatically enable other traits:

traits: [
    .trait(name: "Basic"),
    .trait(name: "Advanced", enabledTraits: ["Basic"]),
    .trait(name: "Full", enabledTraits: ["Advanced"]),
]

Enabling Full gives you everything. Enabling Basic gives you just the basics.

Checking Traits in Code

You can only check your own package’s traits with #if. You cannot directly check a dependency’s traits - map them to local traits as shown in the conditional enabling section above.

#if UseLogging  // Check your local trait
import Logging
#endif

When to Use Traits

Good use cases:

Avoid:

Xcode Gotcha

Xcode caches aggressively. If you change traits and things seem broken, clean your build folder and derived data. This is a known pain point that Apple is hopefully addressing.