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.