Trailing commas in lists, metatype key paths, diagnostic groups, and more.

swift-evolution-9.jpg

This year's spring release of Swift brings with it a number of welcome additions and refinements that will make small but important improvements to many projects.

Things like support for trailing commas in lists have been on many people's wish lists for some time, and the extended use of nonisolated helps work around one of the pain points folks encounter with Swift concurrency, but ultimately these changes are mostly minor and provide a neat run up to larger changes that will come in Swift 6.2.

Let's take a look at what's changing…

Allow trailing comma in comma-separated lists

SE-0439 adjusts Swift's syntax so that trailing commas are now permitted in arrays, dictionaries, tuples, function calls, generic parameters, string interpolation, and indeed anywhere a list of items is bound by parentheses (), brackets [], or angle brackets <>.

So, this kind of code is now allowed:

func add<T: Numeric,>(_ a: T, _ b: T,) -> T {
    a + b
}

let result = add(1, 5,)
print(result,)

In practice you're unlikely to write that kind of code, but this feature is really useful in code like this:

import Foundation

let message = "Reject common sense to make the impossible possible."

let range = message.range(
    of: "impossible",
    options: .caseInsensitive
)

You can see the call to range(of:options) is split across multiple lines, which is a fairly common coding style. It looks for the string "impossible" in a case-insensitive search, but thanks to the support for trailing commas we can comment out the whole options: .caseInsensitive line entirely without breaking our code – the line before ends with a trailing comma, but that's allowed from Swift 6.1 onwards.

The restriction for lists bound within delimiters such as parentheses means this kind of code will not compile:

// enum IceCream {
//     case vanilla, chocolate, strawberry,
// }

Metatype Keypaths

SE-0438 extends key paths to support static properties of types, further rounding out the power of key paths in Swift.

For example, we could define a struct with one static variable and one instance variable like this:

struct WarpDrive {
    static let maximumSpeed = 9.975
    var currentSpeed = 8.0
}

When it comes to the currentSpeed property, this can be accessed using the same key path as before:

let currentSpeed = \WarpDrive.currentSpeed

But to access the static maximumSpeed property as a key path, we need to use WarpDrive.Type rather than just WarpDrive, like this:

let maxSpeed = \WarpDrive.Type.maximumSpeed

If you're using type annotations, you would put the WarpDrive.Type part into the type like this:

let specificType: KeyPath<WarpDrive.Type, Double> = \.maximumSpeed

Allow TaskGroup's ChildTaskResult Type To Be Inferred

SE-0442 makes a small but important change to the way we create task groups: when using withTaskGroup() and withThrowingTaskGroup(), we can now skip the of parameter and have Swift infer the task group type based on how we use it.

So, this kind of code is now allowed:

func printMessage() async {
   let string = await withTaskGroup { group in
       group.addTask { "Hello" }
       group.addTask { "From" }
       group.addTask { "A" }
       group.addTask { "Task" }
       group.addTask { "Group" }

       var collected = [String]()

       for await value in group {
           collected.append(value)
       }

       return collected.joined(separator: " ")
   }

   print(string)
}

Previously we needed to write withTaskGroup(of: String.self), but Swift now figures it out for us.

This does not change the behavior of the code – you still need to return the same type from each child task. All that's changing is that Swift now infers that type for you automatically rather than you needing to provide it explicitly.

Allow nonisolated to prevent global actor inference

SE-0449 extends the nonisolated keyword so that it can be applied to protocols, structs, classes, and enums, allowing them to opt out of global actor inference.

As an example, it's common to have a data layer that runs on a specific actor to ensure all reading and writing takes place safely:

@MainActor
class DataController {
    func load() { }
    func save() { }
}

We could then wrap that up inside a protocol used for types that work with our data controller, ensuring they also run on the main actor:

@MainActor
protocol DataStoring {
    var controller: DataController { get }
}

And now everywhere we conform to DataStoring, our type is automatically inferred to be on the main actor, just like DataController, so we can access its methods without using await:

struct App: DataStoring {
    let controller = DataController()

    init() {
        controller.load()
    }
}

In this example the chain of global actor inference is simple and clear: App conforms to DataStoring, and because DataStoring is restricted to work on the main actor that gets inherited by App.

But what if App conforms to several protocols – which one caused the actor inference? Or what if DataStoring itself isn't marked @MainActor, but instead inherits from another protocol that is. The same inference gets implied, but it's harder to track where it comes from, and it's not hard to find a type inherits actor isolation when it really isn't needed.

From Swift 6.1 on we can mark protocols, structs, classes, and enums as being nonisolated, allowing them to opt out of actor isolation they inherited from elsewhere. This means you'll need to use await and similar to access any actor-isolated data you would otherwise have been able to use freely, like this:

nonisolated struct App2: DataStoring {
    let controller = DataController()

    init() async {
        await controller.load()
    }
}

Member import visibility

SE-0444 clears up Swift's import rules, and although it's likely to cause a few compile errors, those errors are trivial to fix and result in more consistent code.

Before Swift 6.1, importing a module in one file could lead to some parts of that module being available elsewhere in your project, which could lead to surprising behavior or perhaps even conflicts.

As an example, you might have two different frameworks being imported into your project: one called GeoKit and another called Maps. Both define their own structs, their own functions, their own extensions, and so on, and – for the most part – if you want to access the public parts of those modules you must put import Maps and/or import GeoKit in each file where you want to use them.

But sometimes things got a little leaky. As an example, let's say the Maps module adds this extension to convert angles from degrees to radians:

public extension Double {
    func toRadians() -> Double {
        self * .pi / 180
    }
}

That's such a common thing to want to do that the GeoKit module adds the same thing, except GeoKit's version is subtly different – its version is marked throws, so that Double.nan (an invalid number) throws an error rather than returning Double.nan.

Now Swift needs to make a choice: which version should be used? Before Swift 6.1 things got a little murky: if you imported Maps in any file and GeoKit in any other file, both would make the toRadians() method available everywhere in your project.

This led to a rather annoying problem: if your ContentView.swift file imported the Maps module and used toRadians(), your code would work correctly. But if later you were editing DetailView.swift – a different file entirely – and decided to add import GeoKit there, that would cause the second toRadians() extension method to made available everywhere in your project, breaking seemingly unrelated code in ContentView.swift.

In Swift 6.1 this has been resolved, but only when the new MemberImportVisibility upcoming feature flag is enabled. This is available as a build setting in Xcode, and adjusts the import rules so that you must explicitly import modules in each file where you want their functionality.

This is likely to break code where imports were transitive – where one Swift file gained access to API because of an import in another Swift file – but the fix is just to add an extra import line here and there to make your usage explicit.

Precise Control Flags over Compiler Warnings

SE-0443 introduces fine-grained control over how the Swift compiler issues warnings and errors, adding more flexibility around the "Suppress warnings" and "Treat warnings as errors" options we had previously.

The first step to using this new feature is to add adjust your target's build settings to include -print-diagnostic-groups in the "Swift Compiler – Custom Flags" section in Xcode. If you're building on the command line, use something like swiftc -print-diagnostic-groups main.swift.

Once you do that, many warnings and errors will give you a little extra information at the end. For example, code like this in a macOS Sequoia target triggers a deprecation warning:

@available(macOS, deprecated: 15.0, renamed: "sequoiaFunction")
func sonomaFunction() { }

sonomaFunction()

That deprecation warning would always have been there, but now there's an extra piece of information at the end: 'sonomaFunction()' was deprecated in macOS 15.0: renamed to 'sequoiaFunction' [DeprecatedDeclaration] – that [DeprecatedDeclaration] part is the diagnostic group this warning belongs to, which is our entry point into controlling how that type of message is applied.

Now we know the diagnostic group we're dealing with, we can modify our Swift compiler flags to treat that type of warning differently. For example, if you add the -Werror DeprecatedDeclaration flag it means "upgrade deprecation warnings to errors." Important: In Xcode these flags must be added one at a time, so your final compiler flags list will be -print-diagnostic-groups, -Werror, DeprecatedDeclaration.

The opposite of -Werror is -Wwarning, which tells Swift that a particular warning should remain a warning even when "Treat warnings as errors" is enabled.

Important: The order these flags are applied affects the end result. If you say "treat all warnings as errors except this one," that's what you'll get, but if you say "ensure deprecations are only a warning, but treat all warnings as errors," then deprecations will be upgraded to errors. Because Xcode applies its build settings in its own order, you might find you need to use -warnings-as-errors manually in Other Swift Flags, positioning it exactly where you want it.

You can find some documentation on Swift's diagnostic groups here, although it's an area that's evolving quickly and that documentation already references things not available in Swift 6.1.

Formalize ‘language mode’ terminology

SE-0441 makes a small but important change to the way we describe Swift versions, clearly separating Swift version from Swift language mode.

Swift continues to evolve quickly, and right now we're in an uncanny valley where many people are using the Swift 6 compiler running in Swift 5 language mode – the compiler is capable of using all the latest and greatest features, but they are disabled for compatibility reasons.

This got a little confusing because of command-line options and help references to "Swift version," which didn't really make it clear whether it was referring to the compiler version or the language mode, so SE-0441 finally clears it up: both internally and externally, Swift now refers to a "Swift language mode" to describe which version of the Swift language is being used in the code.

Swift Testing: Range-based confirmations

ST-0005 upgrades the confirmation() function to allow a range of completion counts rather than a single fixed value.

For example, we might have a simple NewsLoader type that fetches news feeds on demand, until there are no more feeds to fetch:

struct NewsLoader: AsyncSequence, AsyncIteratorProtocol {
    var current = 1

    mutating func next() async -> Data? {
        defer { current += 1 }

        do {
            let url = URL(string: "https://hws.dev/news-\(current).json")!
            let (data, _) = try await URLSession.shared.data(from: url)
            return data.isEmpty ? nil : data
        } catch {
            return nil
        }
    }

    func makeAsyncIterator() -> NewsLoader {
        self
    }
}

When writing tests for that, we might know ahead of time that we expect exactly five feeds to be loaded and returned, but we might also happy as long as at least five are returned. In Swift 6.1 and later, Swift Testing's confirmation() function now accepts a range, like this:

import Testing

@Test func fiveToTenFeedsAreLoaded() async throws {
    let loader = NewsLoader()

    await confirmation(expectedCount: 5...10) { confirm in
        for await _ in loader {
            confirm()
        }
   }
}

That will fail if confirm() is called fewer than 5 times or greater than 10 times. You can also use some partial ranges here, such as ensuring confirm() is called at least five times:

@Test func atLeastFiveFeedsAreLoaded() async throws {
    let loader = NewsLoader()

    await confirmation(expectedCount: 5...) { confirm in
        for await _ in loader {
            confirm()
        }
   }
}

Ranges without lower bounds, e.g. confirmation(expectedCount: ...10), are explicitly disallowed to avoid confusion – it's not clear whether it means "up to 10 times" (counting from 1) or "up to 11 times" (counting from 0), so to avoid problems it's just disallowed.

Swift Testing: Return errors from #expect(throws:)

ST-0006 deprecates both #expect(_:sourceLocation:performing:throws:) and #require(_:sourceLocation:performing:throws:) – they used a trailing closure to run some code for evaluation, then used a second trailing closure to check whether the error that was thrown was expected or not.

From Swift 6.1, both #expect(throws:) and #require(throws:) have been updated to return an error of the type they are checking for, allowing you to run the expectation and error evaluation separately.

As an example, you might have code that ensures playing video games is disallowed early in the morning or late in the evening:

enum GameError: Error {
    case disallowedTime
}

func playGame(at time: Int) throws(GameError) {
    if time < 9 || time > 20 {
        throw GameError.disallowedTime
    } else {
        print("Enjoy!")
    }
}

With the old, deprecated API you might check for an exact error type like this:

import Testing

@Test func playGameAtNight() {
    #expect {
        try playGame(at: 22)
    } throws: {
        guard let error = $0 as? GameError else { return false }
        // perform additional error validation here
        return error == .disallowedTime
    }
}

You should move that over to code that runs the expectation and error evaluation separately, like this:

@Test func playGameAtNight() {
    // `error` will now be a GameError
    let error = #expect(throws: GameError.self) {
        try playGame(at: 22)
    }

    // perform additional validation here
    #expect(error == .disallowedTime)
}

Swift Testing: Test Scoping Traits

ST-0007 introduces test scoping traits, providing fine-grained, concurrency-safe access to shared test configurations, so that we're able to set up a precise environment for tests to run inside without the risk of race conditions.

As an example, we might start with a Player struct such as this one:

struct Player {
    var name: String
    var friends = [Player]()

    @TaskLocal static var current = Player(name: "Anonymous")
}

Notice the @TaskLocal attribute attached to the current property. Swift concurrency doesn't let us create shared mutable state, but @TaskLocal solves that by placing a shared instance of a player directly inside each task in a way that is concurrency-safe – code inside one task can read the shared value safely, whereas code in other tasks will have their own Player values.

We can then go ahead and use Player.current in any regular production code, as if it were a singleton. Of course, it isn't actually a singleton because each task has its own unique value, but that isn't exposed in our code. So, we can write things like this:

func createWelcomeScreen() -> String {
    var message = "Welcome, \(Player.current.name)!\n"
    message += "Friends online: \(Player.current.friends.count)"
    return message
}

So far this is just regular Swift concurrency code. But here's where test scopes come in: Swift Testing leans heavily on running its tests concurrently, so it can executes thousands of unit tests a second. With test scopes, we can place a custom task local Player.current value around specific tests, ensuring that the code inside that test has exact values in place without risking shared mutable state across tests running concurrently.

Creating a test scope means conforming to two protocols: the core TestTrait protocol, and also the TestScoping protocol introduced in Swift 6.1. The only requirement between these two comes from TestScoping, which makes us implement a provideScope() method that knows how to configure the environment for a test.

In our case, we want to create a Player instance with precise values, put that in place as a task local, then run the test. Here's how that looks:

import Testing

struct DefaultPlayerTrait: TestTrait, TestScoping {
    func provideScope(
        for test: Test,
        testCase: Test.Case?,
        performing function: () async throws -> Void
    ) async throws {
        let player = Player(name: "Natsuki Subaru")

        try await Player.$current.withValue(player) {
            try await function()
        }
    }
}

The most important part there comes at the end – the Player.current.withValue(player) block. That calls function(), which is the test we're working with, but it does so by ensuring we have a custom value in place for Player.current for the whole time that test runs.

That's enough to use the new test scope immediately, but it's a good idea to add a small Trait extension so that your custom trait fits in with the rest of the built-in traits:

extension Trait where Self == DefaultPlayerTrait {
    static var defaultPlayer: Self { Self() }
}

And now we can go ahead and write tests using our custom scope:

@Test(.defaultPlayer) func welcomeScreenShowsName() {
    let result = createWelcomeScreen()
    #expect(result.contains("Natsuki Subaru"))
}

Just using @Test(.defaultPlayer) is enough to trigger our custom scope – when createWelcomeScreen() is run, its copy of Player.current will be the custom one we configured in our scope, rather than the default.

If you want more than one task local value to be configured, you have two choices. If they must both be in place for a single configuration, you should nest your task locals inside additional withValue() calls. Alternatively, if each value can be used independently or in various other arrangements, you can create multiple test scopes and include the ones you need using @Test(.firstScope, .secondScope, .thirdScope), and so on. Swift Testing creates the whole test environment by running each scope in the order you list them, which means it's possible for .thirdScope to selectively overwrite values put in place by .firstScope.

Test scopes are designed to complement existing Swift Testing features rather than replace them – you can still use init() and deinit() to perform custom set up and teardown work as needed, for example. The different with scopes is that they let us opt into configurations for individual tests or whole suites as needed.

And there's more…

In addition to the above, there are further changes to Swift that might impact you:

  • SE-0387 makes it significantly easier to build Swift one platform that targets a different one, such as building an x86-64 Linux binary using macOS with Apple Silicon.
  • SE-0436 allows Swift to replace implementation code from Objective-C.
  • SE-0450 introduces the ability for Swift packages to provide optional traits that add or remove features such as experimental API, and when you import a package you can choose to enable traits of your choosing.

So, although Swift 6.1 doesn't present anything revolutionary, it definitely includes a range of changes that help polish the language and make it more consistent – inferring child task types, fixing up import visibility, and metatype key paths really go a long way to round things out.

Swift 6.2 will almost certainly arrive at WWDC25, and will include another batch of changes aimed at refining Swift concurrency amongst other improvements. I'm expecting big things, but a lot will depend on the exact feature configuration Xcode enables out of the box…