Swift 6 Strict Concurrency: 4 Migration Mistakes and How to Fix Them
DEV Community

Swift 6 Strict Concurrency: 4 Migration Mistakes and How to Fix Them

I enabled Swift 6 mode on a project I'd been shipping for two years. Sixty-plus errors. My first reaction was to roll it back. I'm glad I didn't. At least a dozen of those were real bugs - data races and actor isolation violations that had been silently waiting to bite me in production. Here are the four patterns behind most of those errors, and exactly how I fixed them. 1. Passing non-Sendable types into a Task This is the most common one. You have a class - say, a ProfileViewModel - and you capture it inside a Task closure without thinking about what that means for thread safety. // โŒ Swift 6 error: capture of 'viewModel' with non-sendable type class ProfileViewModel { var username: String = "" } Task { await fetchProfile(for: viewModel) } ProfileViewModel is a reference type with mutable state. Two contexts can now read and write it at the same time. That's a data race. The cleanest fix is converting to a struct - value types get Sendable for free: // โœ… Sendable by default struct ProfileViewModel: Sendable { let username: String } If you genuinely need reference semantics and mutable state, reach for an actor instead. And if you're wrapping something thread-safe that predates Swift concurrency, @unchecked Sendable is fine - but document why it's safe. 2. Accessing @MainActor properties from an unstructured Task You fetch some data in a Task , then try to update a @MainActor property directly. Swift 6 blocks it. // โŒ Task doesn't inherit @MainActor automatically func loadFeed() { Task { let fetched = await FeedService.shared.fetchPosts() posts = fetched // โš ๏ธ isolation violation } } The fix depends on the class. If your view model or view controller is entirely UI-bound, just annotate the whole class with @MainActor : // โœ… Cleanest option for UI-bound types @MainActor func loadFeed() { Task { let fetched = await FeedService.shared.fetchPosts() posts = fetched // fine - already on @MainActor } } Otherwise, hop back explicitly: await MainActor.run { posts = fetched } KISS applies here. The simpler your isolation model, the harder it is to get wrong. 3. Using nonisolated and @unchecked Sendable as escape hatches When you have 60 errors, these keywords look like lifelines. One annotation and the red goes away. The compiler goes quiet. The underlying problem doesn't. Both have legitimate uses - nonisolated is right for actor methods that don't touch mutable state, and @unchecked Sendable is fine for manually thread-safe types like a lock-protected cache. But if you can't immediately explain why something is safe, that's the signal to fix the data flow, not suppress the warning. I've seen codebases where every model class was @unchecked Sendable . Every single one was a ticking time bomb. 4. Assuming async functions inherit actor context This one is subtle and it's easy to miss, because it behaved fine in Swift 5. If you have a @MainActor class that calls an async helper, the helper does not automatically run on the main actor: // โŒ loadTitle runs on a generic executor, not @MainActor @MainActor class DashboardViewModel: ObservableObject { @Published var title: String = "" func refresh() { Task { await loadTitle() } } } func loadTitle() async { let result = await TitleService.fetch() title = result // โš ๏ธ Swift 6 error } The async keyword doesn't carry context forward. Task { } starts fresh. Fix it by annotating the function directly: // โœ… Explicit is always better than implicit @MainActor func loadTitle() async { let result = await TitleService.fetch() title = result } Rule of thumb: if a function writes to @Published properties, it belongs on @MainActor . Define the contract at the function - don't scatter it across every call site. The migration approach that actually worked for me Don't turn on Swift 6 mode everywhere at once. Here's the order that worked: - Set SWIFT_STRICT_CONCURRENCY = targeted in build settings first. It surfaces the critical issues without the full error count. - Write all new modules in Swift 6 mode from day one. - Fix Sendable errors first, then actor isolation, then clean up any suppressions. - Replace print withLogger as you touch files - signals intent, not just patching. import OSLog private let logger = Logger(subsystem: "com.yourapp", category: "Migration") logger.debug("Actor isolation resolved for FeedViewModel") You'll learn more from 20 well-understood fixes than 200 suppressions. Full article with diagrams and a complete reference list on the blog ๐Ÿ‘‰ Swift 6 Strict Concurrency: Common Migration Mistakes and How to Fix Them Part 3 of the Swift Concurrency in Production series on idiotswithios.com Top comments (0)

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.