Refactoring for Swift 6 Concurrency
Practical refactoring tips for migrating to Swift 6 concurrency: @Sendable, sending, region-based isolation, and common error fixes.
When migrating to Swift 6 and adopting @Sendable, sending, and region-based isolation, you're likely to run into a series of compiler errors. Here's how you can identify patterns and apply effective refactors.
π Common Migration Errors & How to Fix Them
β Error: Sending value of non-Sendable type '() async throws -> ()' risks causing data races
Before:
swiftclass User { // User properties and methods var id: String = "12345" } func buildUserProfile() async throws { let user = User() Task { [user] in try await sendAnalyticsEvents(for: user) // β } user.id = "67890" } func sendAnalyticsEvents(for user: User) async throws { // Simulate sending analytics events print("Sending analytics events for user with ID: (user.id)") }
After: Think of models rather as immutable states, structures
swift// Making a struct instead of a class struct User { var id: String = "12345" } func buildUserProfile() async throws { var user = User() Task { [user] in try await sendAnalyticsEvents(for: user) // β this will be captured value } user.id = "67890" // no affect on the sent value }
Or you can make user a Sendable type
swift// Making User conform to Sendable: final and immutable final class User: Sendable { let id: String = "12345" } func buildUserProfile() async throws { let user = User() Task { [user] in try await sendAnalyticsEvents(for: user) // β this will be captured value } // user.id = "67890" // will not compile, as User is immutable }
β Error: Sending 'self.viewModel' risks causing data races
Before:
swiftfinal class ViewModel { func loadItems() async throws -> [String] { try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second delay return ["Item 1", "Item 2", "Item 3"] } } struct ContentView: View { var viewModel = ViewModel() var body: some View { EmptyView() .task { _ = try? await viewModel.loadItems() // β } } }
Fix: Marking viewModel as @MainActor to match the view's actor context.
swift@MainActor β final class ViewModel { ...
Or isolate your logic in a @MainActor wrapper function.
β Error: Static property 'shared' is not concurrency-safe because non-'Sendable' type 'DateFormatterCache' may have shared mutable state
Before:
swiftclass DateFormatterCache { static let shared = DateFormatterCache() // β }
Fix: use an actor and mark methods as needed actors
swiftactor DateFormatterCache { static let shared = DateFormatterCache() // β @MainActor // β func format(date: Date) -> String { let formatter = DateFormatter() formatter.dateStyle = .medium return formatter.string(from: date) } }
π οΈ General Refactoring Strategies
β
Use @MainActor/@globalActor
Helps isolate UI logic and avoids implicit captures.
π§΅ Adopt sending for closures
Where applicable, use the sending keyword for closure parameters to express safe, region-based logic.
π§° Extract Logic from Closures
Move complex operations or risky captures into named functions or actor contexts.
π¦ Reduce Concurrency Where Possible
Instead of scattering Task everywhere, centralize async logic or defer it through controlled actors or services.
π Use @unchecked Sendable Cautiously
Only when you're confident in thread-safety and isolation. Mark explicitly and document your reasoning.
π§ Checklist for Migrating Code
- Audit all
Taskand closure captures for Sendable compliance - Use
@MainActorfor UI and view model logic - Refactor shared mutable state to actors or thread-safe patterns
- Prefer region-based isolation and
sendingwhere possible - Document all uses of
@unchecked Sendable - Run tests for concurrency bugs after migration
By applying these patterns early, you can turn the Swift 6 migration into a progressive hardening of your architecture β not just a compiler whack-a-mole.
π§ Treat errors as opportunities to make code more explicit, more isolated, and safer by design.