NashTech Blog

Swift 6: Key Changes and How to Migrate Your Projects

Table of Contents

Introduction

Swift 6 is a big step forward for the Swift programming language, aiming to make app development safer, more reliable, and easier to manage — especially when apps do many things at once (like loading data in the background or responding to user actions quickly). The new rules in Swift 6 help developers avoid tricky bugs that can happen when multiple tasks run at the same time. However, to gain these benefits, developers will need to adjust some of their old coding habits to follow the new standards.

If you decide not to upgrade from Swift 5 to Swift 6, you might not face immediate issues, but you’ll miss out on important improvements like better concurrency management and increased safety. This could lead to technical problems in the future, as new libraries and features may require Swift 6 or a later version.
Upgrading to Swift 6 ensures that your code is up-to-date, keeps pace with the latest advancements, and is easier to maintain in the future, as Swift 6 provides a more stable and predictable development foundation. Not upgrading may cause you to miss out on new tools, performance improvements, and security patches.

For those who have not heard of Swift, it is a modern programming language developed by Apple in 2014. Designed to be fast, safe, and easy to use, Swift is ideal for building applications across Apple platforms such as iOS, macOS, watchOS, and tvOS. Refer Swift language https://www.swift.org/

1. Strict Concurrency Enforcement

1.1 What is Concurrency Enforcement?

Swift 6 now strictly enforces concurrency access rules. You must properly use await when calling actor methods and ensure isolated data is not accessed incorrectly. This makes your code safer and prevents unexpected behaviors.

Impact from Swift 5 → Swift 6: In Swift 5.x, missing await would still compile, but in Swift 6, it will cause a compile-time error.

1.2 Unsafe Example Before Swift 6

Assume that LikeManager is responsible for managing the number of likes on a post. When a user taps the “Like” button, the likePost() method is called. If await is not used, this can lead to a race condition when multiple tasks access it concurrently.

In Swift 6, the compiler will require await when calling an actor’s method. In the example with the LikeManager actor on Swift 5.

import Foundation

actor LikeManager {
    var likeCount = 0

    func likePost() {
        likeCount += 1
    }

    func getCount() -> Int {
        return likeCount
    }
}

let manager = LikeManager()

// Send 1000 "Like" actions from multiple concurrent Tasks
for _ in 0..<1000 {
    Task.detached {
        manager.likePost() // ❌ No 'await' — Compiles in Swift 5.x, but may cause a race condition
    }
}

// Wait a bit and then print the result
Task {
    try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
    let result = await manager.getCount()
    print("🔥 Total likes: \(result)") // ⚠️ May be less than 1000 due to race condition
}

// 🔥 Expected result: number likes: 1000
// 🔥 Actual result: number likes: 874

1.3 Correct Example After Swift 6 (forces the use of await)

import Foundation

actor LikeManager {
    var likeCount = 0

    func likePost() {
        likeCount += 1
    }

    func getCount() -> Int {
        return likeCount
    }
}

let manager = LikeManager()

// Send 1000 "Like" actions from multiple concurrent Tasks
for _ in 0..<1000 {
    Task.detached {
        await manager.likePost() // ✅ Using 'await' ensures safe access to the actor
    }
}

// Wait a bit and then print the result
Task {
    try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
    let result = await manager.getCount()
    print("🔥 Total likes: \(result)") // ✅ Should be 1000 as the race condition is prevented
}

// 🔥 Expected result: number likes: 1000
// 🔥 Actual result: number likes: 1000

1.4 Why Strict Enforcement Matters

  • 1. Prevents Silent Race Conditions
    • Strict enforcement of await helps prevent race conditions that may go unnoticed in production, ensuring safer, more predictable behavior, especially in concurrent environments.
  • 2. Establishes Clear Concurrency Contracts
    • By requiring await for actor methods, Swift 6 sets clear rules for developers on how to safely interact with actors. This prevents unsafe concurrency and makes the code easier to reason about.
  • 3. Enables Better Compiler Optimizations
    • The compiler can optimize task scheduling and runtime performance when it knows that actor state is serialized. This reduces overhead from synchronization mechanisms and improves efficiency

2. Mandatory Sendable Conformance

2.1 What is Sendable?

Sendable is a protocol that guarantees a type is safe to be shared across concurrent domains (e.g., tasks, actors). Types that conform to Sendable can be safely used in concurrent contexts.

2.2 Issues with Missing Sendable Before Swift 6

Let’s imagine you’re building a user management app or social media app, where you need to asynchronously save user information to a database.

struct User {
    var name: String
    var age: Int
}

func saveUserToDatabase(_ user: User) {
    Task.detached {
        // 🚨 Unsafe: User might mutate while used
        print("Saving user \(user.name) to database")
    }
}

Problem: Here, the User struct is not Sendable, and in a concurrent environment, there is a risk of a race condition if the User object is mutated while being used in a detached task. In Swift 5.x, this code would compile with only a warning but still run, potentially leading to issues.

Impact from Swift 5 → Swift 6:
In Swift 5.x: This would generate only a warning, but it can still run, leaving room for unsafe behavior in concurrent situations.
In Swift 6: The code will result in a compilation error because User is not Sendable, and Swift 6 strictly enforces that types used across concurrency boundaries must conform to Sendable.

2.3 Correct Sendable Usage in Swift 6

To ensure that the User struct is safe to use in concurrent tasks, we must mark it as Sendable. This tells the compiler that the User type can be safely used in a concurrent context without risking mutations or race conditions.

struct User: Sendable {
    var name: String
    var age: Int
}

func saveUserToDatabase(_ user: User) {
    Task.detached {
        // ✅ Safe because User is Sendable
        print("Saving user \(user.name) to database")
    }
}

Sendable: By marking User as Sendable, we ensure that the object can be safely shared between threads or tasks without risk of being mutated while in use. The code is now safe and will compile without errors in Swift 6 because the User type conforms to Sendable.

Real-World Application in a Project:

In a real-world project, imagine you’re building a user management app that includes features like:

  • Saving user data to the database.
  • Updating user information from a server.
  • Sharing user data between concurrent tasks.

Using Sendable ensures that user data is processed safely when interacting with asynchronous tasks, such as fetching data or saving it to the database, without running into data synchronization or race condition issues.

2.4 Unchecked Sendable: When and How

@unchecked Sendable is a feature in Swift that allows developers to manually ensure thread safety when using concurrency, without the need for the compiler to verify that safety. This is useful when you’re sure that your code will behave safely in a concurrent context, but you can’t or don’t want to rely on Swift’s automatic checks for Sendable conformance.

When to Use @unchecked Sendable:

  • You know your code is thread-safe but Swift can’t automatically verify it. For example, your class or object might use thread synchronization mechanisms like locks, DispatchQueue, or other techniques that guarantee thread safety, but Swift doesn’t automatically infer this.
  • You want to share an object between concurrency domains (like Tasks, async functions, etc.), but the object doesn’t conform to Sendable by default. By marking it as @unchecked Sendable, you’re telling the compiler that you’re guaranteeing the safety yourself.
  • You can’t conform the object to Sendable directly, because it might involve reference types (like classes) that Swift doesn’t automatically make Sendable. In these cases, @unchecked Sendable is a way to sidestep the compiler’s constraints while ensuring safety manually.

Use @unchecked Sendable if you manually guarantee thread-safety.

final class Logger: @unchecked Sendable {
    func log(_ message: String) {
        print(message)
    }
}

⚠️ Warning: Only use @unchecked when absolutely necessary.

2.5 Why Sendable Enforcement is Crucial

  • Prevents mysterious crashes due to concurrent value mutations.
  • Easier reasoning about async code.
  • Lays the foundation for scalable and stable Swift apps.

3. Actor Isolation Requirements

3.1 Understanding Actor Isolation

Actors guarantee that only one task can interact with their state at a time. Swift 6 requires proper annotations when crossing isolation boundaries (i.e., interacting with an actor’s state from outside the actor).

3.2 Actor Interaction Before Swift 6

actor DataManager {
var data: [String] = []

func add(item: String) {
data.append(item)
}
}

let manager = DataManager()

Task {
manager.add(item: "Item") // ❌ No 'await'
}

Real-World Scenario: Saving Cache or Token from Multiple Tasks

In an app with network-heavy workflows (such as calling APIs to fetch user tokens, refresh tokens, cache data, etc.), you often have an actor like TokenCacheManager to safely store tokens from multiple concurrent tasks.

Before Swift 6, calling a mutating actor method without await would not trigger any compiler warnings or errors, but it would behave unsafely.

BAD EXAMPLE — Missing await in actor call (pre-Swift 6)

actor TokenCacheManager {
    private var cachedToken: String?

    func save(token: String) {
        cachedToken = token
        print("✅ Token saved: \(token)")
    }

    func getToken() -> String? {
        return cachedToken
    }
}

let cacheManager = TokenCacheManager()

func loginUserAndSaveToken() {
    Task {
        let token = "abc123"
        cacheManager.save(token: token) // ❌ No 'await' — unsafe!
    }
}

No compiler error, but:
– Calling save() is unsafe — actor methods must be awaited from outside.
Possible race conditions or incorrect behavior.
– When migrating to Swift 6, this code will fail to compile because Swift 6 requires explicit await.

GOOD EXAMPLE — Correct use of await (Swift concurrency best practice)

actor TokenCacheManager {
    private var cachedToken: String?

    func save(token: String) {
        cachedToken = token
        print("✅ Token saved: \(token)")
    }

    func getToken() -> String? {
        return cachedToken
    }
}

let cacheManager = TokenCacheManager()

func loginUserAndSaveToken() {
    Task {
        let token = "abc123"
        await cacheManager.save(token: token) // ✅ Correct use of 'await'
    }
}

Expected Output (Console)
– Token saved: abc123
– Fetched from cache: abc123

You’re seeing both the successful save and retrieval, which wouldn’t happen reliably without await.
await ensures the TokenCacheManager‘s methods are executed safely and correctly.

3.3 Proper Actor Interaction After Swift 6

In Swift 6, a significant change was introduced in how actors interact with other code. The primary rule is that actor methods must always be called with await, even if the method doesn’t perform any asynchronous tasks internally. This is a safety feature designed to prevent potential data races and concurrency issues that could arise when accessing actor-isolated data from multiple tasks simultaneously.

Why the Change?

Prior to Swift 6, it was possible to call actor methods without await, and the compiler wouldn’t raise any errors or warnings. However, this could lead to unsafe interactions with actors and result in hard-to-debug concurrency bugs.

With Swift 6, the compiler now enforces this rule:
This ensures that all actor methods are properly awaited and executed in the correct order, preventing race conditions.

Here are two clear versions:
Case 1: Swift 5 – Missing await, the code doesn’t raise an error, but it may produce incorrect results (race condition or unexpected state).
Case 2: Swift 6await is required, ensuring the state is always correct.

CASE 1: Swift 5 — Missing await, potential incorrect output
actor NetworkStatusManager {
    private var isConnected: Bool

    func updateStatus(to status: Bool) {
        isConnected = status
        print("📡 Network status updated: \(status ? "Connected" : "Disconnected")")
    }

    func getStatus() -> Bool {
        return isConnected
    }
}

let networkManager = NetworkStatusManager()

func simulateNetworkIssue() {
    Task {
        // ❌ Missing await — Swift 5 doesn't raise an error!
        networkManager.updateStatus(to: false)

        try? await Task.sleep(nanoseconds: 1_000_000_000)

        networkManager.updateStatus(to: true)

        let current = await networkManager.getStatus()
        print("🔍 Current network status: \(current ? "Connected" : "Disconnected")")
    }
}
simulateNetworkIssue()

Actual Output (Swift 5.x)
- Current network status: Disconnected
- Even though updateStatus(to: true) was called last, because await was missing, the update may not occur at the correct time, or it might get skipped due to a concurrency bug.
CASE 2: Swift 6 — Using await correctly, output is accurate
actor NetworkStatusManager {
    private var isConnected: Bool = true

    func updateStatus(to status: Bool) {
        isConnected = status
        print("📡 Network status updated: \(status ? "Connected" : "Disconnected")")
    }

    func getStatus() -> Bool {
        return isConnected
    }
}

let networkManager = NetworkStatusManager()

func simulateNetworkIssueFixed() {
    Task {
        await networkManager.updateStatus(to: false)

        try? await Task.sleep(nanoseconds: 1_000_000_000)

        await networkManager.updateStatus(to: true)

        let current = await networkManager.getStatus()
        print("🔍 Current network status: \(current ? "Connected" : "Disconnected")")
    }
}
Correct Output (Swift 6)
- Network status updated: Disconnected
- Network status updated: Connected
- Current network status: Connected
Summary:
Swift 5: No compiler error when await is missing, but it may lead to incorrect or unsafe behavior.
Swift 6: Compiler enforces the use of await, ensuring the behavior is thread-safe and predictable.

3.4 Using nonisolated Correctly (With Real-World Example)

In real-world projects, you often have methods in an actor that are just for returning information or formatting data — without accessing or modifying the actor’s internal state. These methods can be marked as nonisolated, allowing them to be called without await, making the code cleaner and more efficient.

Real-World Example:
Suppose you have an actor that manages network data:

actor NetworkManager {
    let baseURL = "https://api.example.com"

    nonisolated func description() -> String {
        "NetworkManager for \(baseURL)"
    }
}

Here, the description() method just returns a simple string — it doesn’t access or modify any state that needs protection. By marking it as nonisolated, you can call this method from anywhere without needing await or worrying about thread safety.

Benefits:

  • Easy to use for logging, debugging, or UI display
  • Cleaner code (no need for await)
  • Avoid unnecessary event loop hops in the actor

3.5 Why Actor Isolation Enforcement Matters

  • Prevents race conditions across tasks.
  • Makes method intentions clear (isolated or free).
  • Optimizes performance by enabling nonisolated methods.

4. Testing Framework Shift: Swift Testing

4.1 Why Move from XCTest to Swift Testing?

XCTest was designed around the dynamic runtime of Objective-C, while Swift Testing is pure Swift, async-native, and uses macros for cleaner, faster tests.

4.2 XCTest Example Before Swift 6

import XCTest

final class MyTests: XCTestCase {
    func testUserName() async throws {
        let user = try await fetchUser()
        XCTAssertEqual(user.name, "Alice")
    }
}
#if !os(macOS)

4.3 Swift Testing Example in Swift 6

import Testing
@testable import MyApp

struct MyTests {
    @Test
    func testUserName() async throws {
        let user = try await fetchUser()
        #expect(user.name == "Alice")
    }
}

4.4 Key Differences Between XCTest and Swift Testing

FeatureXCTestSwift Testing
Test DiscoveryRuntime (slow)Compile-time (fast)
Async SupportAdded laterNative support
SyntaxVerbose, class-basedLightweight, struct-based
Objective-C DependencyYesNo

Impact from Swift 5 → Swift 6:
Swift Testing is now preferred;
XCTest still works but is considered legacy for Swift-native projects.

5. Migration Strategy & Best Practices

5.1 Step-by-Step Migration Plan

  • Enable concurrency warnings in your current Swift 5.x project.
    • To enable complete concurrency checking in an Xcode project, set the “Strict Concurrency Checking” setting to “Complete” in the Xcode build settings. Alternatively, you can set SWIFT_STRICT_CONCURRENCY to complete in an xcconfig file:
    • // In a Settings.xcconfig SWIFT_STRICT_CONCURRENCY = complete;
  • Add missing await on actor interactions.
  • Make your types Sendable where necessary.
  • Gradually rewrite tests with Swift Testing.
  • Enable Swift 6 mode once the project compiles cleanly.

5.2 Common Pitfalls to Avoid

  • Ignoring actor isolation errors.
  • Overusing @unchecked Sendable recklessly.
  • Forgetting to update your test suite.

5.3 Useful Compiler Flags for Migration

-enable-upcoming-feature ConcurrencyChecks
-warn-concurrency
-warn-actor-isolation

6. Additional New Syntax Features in Swift 6

Swift 6 introduces several new syntax improvements that make code more concise, readable, and easier to work with. These enhancements help developers write safer and more efficient code while reducing the boilerplate.

6.1 Typed Throws (Explicit Error Types)

One of the key improvements in Swift 6 is typed throws, which allows developers to specify the exact type of errors that a function can throw. Before Swift 6, when throwing an error, there was no restriction on what type of error could be thrown, making it difficult to predict the kind of errors a function might encounter. Swift 6 enforces that you define the error types upfront.

Example:

enum MyError: Error {
    case invalidInput
    case networkFailure
}

func fetchData() throws -> String {
    // Only throws MyError types
    throw MyError.invalidInput
}

This makes your code clearer and safer, as consumers of your function will know exactly what kinds of errors to expect.

6.2 Regex Literals

Swift 6 introduces regex literals, which allow you to write regular expressions directly within your code without needing to create NSRegularExpression objects manually. This change simplifies common tasks like input validation and pattern matching, making the code more concise and readable.

Example:

let regex = /\d+/ // Matches one or more digits
let result = "123abc" firstMatch(of: regex)
print (result)
// Optional ("123" )

This is a huge improvement for developers who frequently work with regular expressions, as it eliminates the need for verbose and error-prone NSRegularExpression syntax.

6.3 Noncopyable Types

Another significant feature in Swift 6 is the introduction of noncopyable types, which prevents a value from being copied. Instead of copying, ownership is transferred when assigning one instance to another. This is particularly useful for sensitive data or resources that should not be duplicated, such as security tokens or sensitive user information.

A noncopyable type can be marked with the new noncopyable attribute, ensuring that values of this type cannot be copied implicitly.

Example:

@noncopyable struct SensitiveData {
let token: String
}
var p1 = SensitiveData (token: "abc123" )
var p2 = p1 // pl's ownership is transferred to p2, not copied

This feature is especially useful for resource management, as it ensures that sensitive data can only be “owned” by one entity at a time, preventing unnecessary or unintended duplication.

7. Conclusion

Swift 6 marks a major milestone with the strictest concurrency safety enforcement and the most powerful testing tools ever introduced. Although the upgrade process may require significant effort, the benefits you gain are well worth it:

  • Your applications become safer and faster.
  • Asynchronous (async) code becomes easier to understand and manage.
  • The testing process becomes cleaner, more modern, and more efficient.

Picture of Nhat Nguyen Thanh

Nhat Nguyen Thanh

Leave a Comment

Your email address will not be published. Required fields are marked *

Suggested Article

Scroll to Top