Migration to Swift 6 and Strict Concurrency
Migration to Swift 6 and Strict Concurrency
Section titled “Migration to Swift 6 and Strict Concurrency”Use this when:
- You are moving an existing codebase toward Swift 6 or stricter concurrency checking.
- Compiler diagnostics depend on language mode, default isolation, or upcoming features.
- You need the smallest safe migration sequence instead of a full architectural rewrite.
Skip this file if:
- You already know the exact diagnostic and only need a local fix. Start from
actors.md,sendable.md, orthreading.md. - You are looking for debounce, stream composition, or FRP operator replacements. Use
async-algorithms.md.
Jump to:
- Project Settings
- Six Migration Habits
- Step-by-Step Migration
- Migration Tooling
- Rewriting Closures to Async/Await
- Migrating from Combine/RxSwift
- Concurrency-Safe Notifications (iOS 26+)
- Anti-Patterns
Why Migrate to Swift 6?
Section titled “Why Migrate to Swift 6?”Swift 6 doesn’t fundamentally change how Swift Concurrency works—it enforces existing rules more strictly:
- Compile-time safety: Catches data races and threading issues at compile time instead of runtime
- Warnings become errors: Many Swift 5 warnings become hard errors in Swift 6 language mode
- Future-proofing: New concurrency features will build on this stricter foundation
- Better maintainability: Code becomes safer and easier to reason about
Important: You can adopt strict concurrency checking gradually while still compiling under Swift 5. You don’t need to flip the Swift 6 switch immediately.
Project Settings That Change Concurrency Behavior
Section titled “Project Settings That Change Concurrency Behavior”Before interpreting diagnostics or choosing a fix, confirm the target/module settings. These settings can materially change how code executes and what the compiler enforces.
Quick matrix
Section titled “Quick matrix”| Setting / feature | Where to check | Why it matters |
|---|---|---|
| Swift language mode (Swift 5.x vs Swift 6) | Xcode build settings (SWIFT_VERSION) / SwiftPM // swift-tools-version: |
Swift 6 turns many warnings into errors and enables stricter defaults. |
| Strict concurrency checking | Xcode: Strict Concurrency Checking (SWIFT_STRICT_CONCURRENCY) / SwiftPM: strict concurrency flags |
Controls how aggressively Sendable + isolation rules are enforced. |
| Default actor isolation | Xcode: Default Actor Isolation (SWIFT_DEFAULT_ACTOR_ISOLATION) / SwiftPM: .defaultIsolation(MainActor.self) |
Changes the default isolation of declarations; can reduce migration noise but changes behavior and requirements. |
NonisolatedNonsendingByDefault |
Xcode upcoming feature / SwiftPM .enableUpcomingFeature("NonisolatedNonsendingByDefault") |
Changes how nonisolated async functions execute (can inherit the caller’s actor unless explicitly marked @concurrent). |
| Approachable Concurrency | Xcode build setting / SwiftPM enables the underlying upcoming features | Bundles multiple upcoming features; recommended to migrate feature-by-feature first. |
The Concurrency Rabbit Hole
Section titled “The Concurrency Rabbit Hole”A common migration experience:
- Enable strict concurrency checking
- See 50+ errors and warnings
- Fix a bunch of them
- Rebuild and see 80+ new errors appear
Why this happens: Fixing isolation in one place often exposes issues elsewhere. This is normal and manageable with the right strategy.
Six Migration Habits for Success
Section titled “Six Migration Habits for Success”1. Don’t Panic—It’s All About Iterations
Section titled “1. Don’t Panic—It’s All About Iterations”Break migration into small, manageable chunks:
// Day 1: Enable strict concurrency, fix a few warnings// Build Settings → Strict Concurrency Checking = Complete
// Day 2: Fix more warnings
// Day 3: Revert to minimal checking if needed// Build Settings → Strict Concurrency Checking = MinimalAllow yourself 30 minutes per day to migrate gradually. Don’t expect completion in a few days for large projects.
2. Sendable by Default for New Code
Section titled “2. Sendable by Default for New Code”When writing new types, make them Sendable from the start:
// ✅ Good: New code prepared for Swift 6struct UserProfile: Sendable { let id: UUID let name: String}
// ❌ Avoid: Creating technical debtclass UserProfile { // Will need migration later var id: UUID var name: String}It’s easier to design for concurrency upfront than to retrofit it later.
3. Use Swift 6 for New Projects and Packages
Section titled “3. Use Swift 6 for New Projects and Packages”For new projects, packages, or files:
- Enable Swift 6 language mode from the start
- Use Swift Concurrency features (async/await, actors)
- Reduce technical debt before it accumulates
You can enable Swift 6 for individual files in a Swift 5 project to prevent scope creep.
4. Resist the Urge to Refactor
Section titled “4. Resist the Urge to Refactor”Focus solely on concurrency changes. Don’t combine migration with:
- Architecture refactors
- API modernization
- Code style improvements
Create separate tickets for non-concurrency refactors and address them later.
5. Focus on Minimal Changes
Section titled “5. Focus on Minimal Changes”- Make small, focused pull requests
- Migrate one class or module at a time
- Get changes merged quickly to create checkpoints
- Avoid large PRs that are hard to review
6. Don’t Just @MainActor All the Things
Section titled “6. Don’t Just @MainActor All the Things”Don’t blindly add @MainActor to fix warnings. Consider:
- Should this actually run on the main actor?
- Would a custom actor be more appropriate?
- Is
nonisolatedthe right choice?
Exception: For app projects (not frameworks), consider enabling Default Actor Isolation to @MainActor, since most app code needs main thread access.
Step-by-Step Migration Process
Section titled “Step-by-Step Migration Process”1. Find an Isolated Piece of Code
Section titled “1. Find an Isolated Piece of Code”Start with:
- Standalone packages with minimal dependencies
- Individual Swift files within a package
- Code that’s not heavily used throughout the project
Why: Fewer dependencies = less risk of falling into the concurrency rabbit hole.
2. Update Related Dependencies
Section titled “2. Update Related Dependencies”Before enabling strict concurrency:
// Update third-party packages to latest versions// Example: Vapor, Alamofire, etc.Apply these updates in a separate PR before proceeding with concurrency changes.
3. Add Async Alternatives
Section titled “3. Add Async Alternatives”Provide async/await wrappers for existing closure-based APIs:
// Original closure-based API@available(*, deprecated, renamed: "fetchImage(urlRequest:)", message: "Consider using the async/await alternative.")func fetchImage(urlRequest: URLRequest, completion: @escaping @Sendable (Result<UIImage, Error>) -> Void) { // ... existing implementation}
// New async wrapperfunc fetchImage(urlRequest: URLRequest) async throws -> UIImage { return try await withCheckedThrowingContinuation { continuation in fetchImage(urlRequest: urlRequest) { result in continuation.resume(with: result) } }}Benefits:
- Colleagues can start using async/await immediately
- You can migrate callers before rewriting implementation
- Tests can be updated to async/await first
Tip: Use Xcode’s Refactor → Add Async Wrapper to generate these automatically.
4. Change Default Actor Isolation (Swift 6.2+)
Section titled “4. Change Default Actor Isolation (Swift 6.2+)”For app projects, set default isolation to @MainActor:
Xcode Build Settings:
Swift Concurrency → Default Actor Isolation = MainActorSwift Package Manager:
.target( name: "MyTarget", swiftSettings: [ .defaultIsolation(MainActor.self) ])This drastically reduces warnings in app code where most types need main thread access.
5. Enable Strict Concurrency Checking
Section titled “5. Enable Strict Concurrency Checking”Xcode Build Settings: Search for “Strict Concurrency Checking”
Three levels available:
- Minimal: Only checks code that explicitly adopts concurrency (
@Sendable,@MainActor) - Targeted: Checks all code that adopts concurrency, including
Sendableconformances - Complete: Checks entire codebase (matches Swift 6 behavior)
Swift Package Manager:
.target( name: "MyTarget", swiftSettings: [ .enableExperimentalFeature("StrictConcurrency=targeted") ])Strategy: Start with Minimal → Targeted → Complete, fixing errors at each level.
6. Add Sendable Conformances
Section titled “6. Add Sendable Conformances”Even if the compiler doesn’t complain, add Sendable to types that will cross isolation domains:
// ✅ Prepare for future usestruct Configuration: Sendable { let apiKey: String let timeout: TimeInterval}This prevents warnings when the type is used in concurrent contexts later.
7. Enable Approachable Concurrency (Swift 6.2+)
Section titled “7. Enable Approachable Concurrency (Swift 6.2+)”Xcode Build Settings: Search for “Approachable Concurrency”
Enables multiple upcoming features at once:
DisableOutwardActorInferenceGlobalActorIsolatedTypesUsabilityInferIsolatedConformancesInferSendableFromCapturesNonisolatedNonsendingByDefault
⚠️ Warning: Don’t just flip this switch for existing projects. Use migration tooling (see below) to migrate to each feature individually first.
8. Enable Upcoming Features
Section titled “8. Enable Upcoming Features”Xcode Build Settings: Search for “Upcoming Feature”
Enable features individually:
Swift Package Manager:
.target( name: "MyTarget", swiftSettings: [ .enableUpcomingFeature("ExistentialAny"), .enableUpcomingFeature("InferIsolatedConformances") ])Find feature keys in Swift Evolution proposals (e.g., SE-335 for ExistentialAny).
9. Change to Swift 6 Language Mode
Section titled “9. Change to Swift 6 Language Mode”Xcode Build Settings:
Swift Language Version = Swift 6Swift Package Manager:
If you’ve completed all previous steps, you should have minimal new errors.
Migration Tooling for Upcoming Features
Section titled “Migration Tooling for Upcoming Features”Swift 6.2+ includes semi-automatic migration for upcoming features.
Xcode Migration
Section titled “Xcode Migration”- Go to Build Settings → Find the upcoming feature (e.g., “Require Existential any”)
- Set to Migrate (temporary setting)
- Build the project
- Warnings appear with Apply buttons
- Click Apply for each warning
Example warning:
// ⚠️ Use of protocol 'Error' as a type must be written 'any Error'func fetchData() throws -> Data // Beforefunc fetchData() throws -> any Data // After applying fixPackage Migration
Section titled “Package Migration”Use the swift package migrate command:
# Migrate all targetsswift package migrate --to-feature ExistentialAny
# Migrate specific targetswift package migrate --target MyTarget --to-feature ExistentialAnyOutput:
> Applied 24 fix-its in 11 files (0.016s)> Updating manifestThe tool automatically:
- Applies all fix-its
- Updates
Package.swiftto enable the feature
Available migrations (as of Swift 6.2):
ExistentialAny(SE-335)InferIsolatedConformances(SE-470)- More features will add migration support over time
Additional resource: Migration Tooling Video
Rewriting Closures to Async/Await
Section titled “Rewriting Closures to Async/Await”Using Xcode Refactoring
Section titled “Using Xcode Refactoring”Three refactoring options available:
- Add Async Wrapper: Wraps existing closure-based method (recommended first step)
- Add Async Alternative: Rewrites method as async, keeps original
- Convert Function to Async: Replaces method entirely
⚠️ Known Issue: Refactoring can be unstable in Xcode. If you get “Connection interrupted” errors:
- Clean build folder
- Clear derived data
- Restart Xcode
- Simplify complex methods (shorthand if statements can cause failures)
Manual Rewriting Example
Section titled “Manual Rewriting Example”Before (closure-based):
func fetchImage(urlRequest: URLRequest, completion: @escaping @Sendable (Result<UIImage, Error>) -> Void) { URLSession.shared.dataTask(with: urlRequest) { data, _, error in do { if let error = error { throw error } guard let data = data, let image = UIImage(data: data) else { throw ImageError.conversionFailed } completion(.success(image)) } catch { completion(.failure(error)) } }.resume()}After (async/await):
func fetchImage(urlRequest: URLRequest) async throws -> UIImage { let (data, _) = try await URLSession.shared.data(for: urlRequest) guard let image = UIImage(data: data) else { throw ImageError.conversionFailed } return image}Benefits:
- Less code to maintain
- Easier to reason about
- No nested closures
- Automatic error propagation
Using @preconcurrency
Section titled “Using @preconcurrency”Suppresses Sendable warnings from modules you don’t control.
When to Use
Section titled “When to Use”// ⚠️ Third-party library doesn't support Swift Concurrency yet@preconcurrency import SomeThirdPartyLibrary
actor DataProcessor { func process(_ data: LibraryType) { // No Sendable warning // ... }}- No compile-time safety: You’re responsible for ensuring thread safety
- Hides real issues: Library might not be thread-safe at all
- Technical debt: Easy to forget to revisit later
Best Practices
Section titled “Best Practices”- Don’t use by default: Only add when compiler suggests it
- Check for updates first: Library might have a newer version with concurrency support
- Document why: Add a comment explaining why it’s needed
- Revisit regularly: Set reminders to check if library has been updated
// TODO: Remove @preconcurrency when SomeLibrary adds Sendable support// Last checked: 2026-01-07 (version 2.3.0)@preconcurrency import SomeLibraryThe compiler will warn if @preconcurrency is unused:
'@preconcurrency' attribute on module 'SomeModule' is unusedMigrating from Combine/RxSwift
Section titled “Migrating from Combine/RxSwift”Observation Alternative
Section titled “Observation Alternative”Swift 6 will include Transactional Observation (SE-475):
// Future API (not yet implemented)let names = Observations { person.name }
Task.detached { for await name in names { print("Name updated to: \(name)") }}Current alternatives:
- Use
@Observablemacro for SwiftUI - Use
AsyncStreamfor custom observation - Consider AsyncExtensions package
Debouncing Example
Section titled “Debouncing Example”Combine:
$searchQuery .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) .sink { [weak self] query in self?.performSearch(query) } .store(in: &cancellables)Swift Concurrency:
func search(_ query: String) { currentSearchTask?.cancel()
currentSearchTask = Task { do { try await Task.sleep(for: .milliseconds(500)) performSearch(query) } catch { // Search was cancelled } }}SwiftUI Integration:
struct SearchView: View { @State private var searchQuery = "" @State private var searcher = ArticleSearcher()
var body: some View { List(searcher.results) { result in Text(result.title) } .searchable(text: $searchQuery) .onChange(of: searchQuery) { _, newValue in searcher.search(newValue) } }}Mindset Shift
Section titled “Mindset Shift”Don’t think in Combine pipelines. Many problems are simpler without FRP:
// ❌ Looking for AsyncSequence equivalent of complex Combine pipelinesomePublisher .debounce(for: .seconds(0.5)) .removeDuplicates() .flatMap { ... } .sink { ... }
// ✅ Rethink the problem with Swift ConcurrencyTask { var lastValue: String? for await value in stream { guard value != lastValue else { continue } lastValue = value try await Task.sleep(for: .seconds(0.5)) await process(value) }}For complex operators: Check Swift Async Algorithms package.
⚠️ Critical: Actor Isolation with Combine
Section titled “⚠️ Critical: Actor Isolation with Combine”Problem: sink closures don’t respect actor isolation at compile time.
@MainActorfinal class NotificationObserver { private var cancellables: [AnyCancellable] = []
init() { NotificationCenter.default.publisher(for: .someNotification) .sink { [weak self] _ in self?.handleNotification() // ⚠️ May crash if posted from background } .store(in: &cancellables) }
private func handleNotification() { // Expects to run on main actor }}Why it crashes: Notification observers run on the same thread as the poster. If posted from a background thread, the @MainActor method is called off the main actor.
Solutions:
- Migrate to Swift Concurrency (recommended):
Task { [weak self] in for await _ in NotificationCenter.default.notifications(named: .someNotification) { await self?.handleNotification() // ✅ Compile-time safe }}- Use Task wrapper (temporary):
.sink { [weak self] _ in Task { @MainActor in self?.handleNotification() }}When to Use AsyncAlgorithms
Section titled “When to Use AsyncAlgorithms”When migrating from Combine or RxSwift, you have multiple options for handling asynchronous patterns:
Use AsyncAlgorithms for:
Section titled “Use AsyncAlgorithms for:”- Time-based operations: debounce, throttle, timers
- Combining multiple async sequences: merge, combineLatest, zip
- Multi-consumer scenarios: AsyncChannel for backpressure
- Complex operator chains: FRP-like patterns in Swift Concurrency
- Specific operators: removeDuplicates, chunks, adjacentPairs, compacted
Use Standard Library for:
Section titled “Use Standard Library for:”- Bridging callbacks: AsyncStream is sufficient
- Simple iteration: for await in sequence
- Single-value operations: async/await
- Basic transformations: map, filter, contains
Use SwiftUI for:
Section titled “Use SwiftUI for:”- UI observation: @Observable macro
- State management: @State, @Published properties
- User interactions: onChange, onReceive modifiers
See: async-algorithms.md for detailed AsyncAlgorithms usage examples.
Real-World Migration Examples
Section titled “Real-World Migration Examples”Example: ArticleSearcher with AsyncAlgorithms
Section titled “Example: ArticleSearcher with AsyncAlgorithms”Before: Manual Debouncing
final class ArticleSearcher { @MainActor private(set) var results: [Article] = [] private var currentSearchTask: Task<Void, Never>?
func search(_ query: String) { currentSearchTask?.cancel()
currentSearchTask = Task { do { try await Task.sleep(for: .milliseconds(500)) await MainActor.run { self.results = [] } self.results = await APIClient.searchArticles(query) } catch { // Search was cancelled } } }}
// SwiftUI integrationstruct SearchView: View { @State private var searchQuery = "" @State private var searcher = ArticleSearcher()
var body: some View { List(searcher.results) { result in Text(result.title) } .searchable(text: $searchQuery) .onChange(of: searchQuery) { _, newValue in searcher.search(newValue) } }}After: AsyncAlgorithms Debounce
import AsyncAlgorithms
@Observablefinal class ArticleSearcher { @MainActor private(set) var results: [Article] = [] private var searchQueryContinuation: AsyncStream<String>.Continuation?
private lazy var searchQueryStream: AsyncStream<String> = { AsyncStream { continuation in searchQueryContinuation = continuation } }()
func search(_ query: String) { searchQueryContinuation?.yield(query) }
func startDebouncedSearch() { Task { @MainActor in for await query in searchQueryStream.debounce(for: .milliseconds(500)) { self.results = [] self.results = await APIClient.searchArticles(query) } } }}
// SwiftUI integrationstruct SearchView: View { @State private var searchQuery = "" @State private var searcher = ArticleSearcher()
var body: some View { List(searcher.results) { result in Text(result.title) } .searchable(text: $searchQuery) .onChange(of: searchQuery) { _, newValue in searcher.search(newValue) } .onAppear { searcher.startDebouncedSearch() } }}Benefits of using AsyncAlgorithms:
- Automatic cancellation when new values arrive
- Backpressure handling (producer respects consumer pace)
- Cleaner code than manual Task.sleep management
- No need to track and cancel tasks manually
Example: Notification Stream Migration
Section titled “Example: Notification Stream Migration”Before: Combine Publisher
import Combine
final class NotificationObserver: ObservableObject { @Published private(set) var notifications: [AppNotification] = [] private var cancellables = Set<AnyCancellable>()
init() { NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification) .compactMap { notification in notification.object as? AppNotification } .receive(on: DispatchQueue.main) .assign(to: &$notifications) }}After: Standard Library Notifications
@Observablefinal class NotificationObserver { @MainActor private(set) var notifications: [AppNotification] = []
func startObserving() { Task { for await notification in NotificationCenter.default.notifications(named: UIApplication.didBecomeActiveNotification) { if let appNotification = notification.object as? AppNotification { notifications.append(appNotification) } } } }}When to use each approach:
- Use
notifications(named:)for standard system notifications - Use
AsyncChannelfor custom multi-consumer notification scenarios - Use
@Observable+ SwiftUI for UI state updates
Example: Multi-Source Data Loading
Section titled “Example: Multi-Source Data Loading”Before: Combine Merge
import Combine
final class MultiSourceLoader: ObservableObject { @Published private(set) var items: [Item] = [] private var cancellables = Set<AnyCancellable>()
func loadFromAllSources() { let source1 = APIClient.fetchItems(from: .source1) let source2 = APIClient.fetchItems(from: .source2) let source3 = APIClient.fetchItems(from: .source3)
Publishers.Merge3(source1, source2, source3) .flatMap { items in Just(items) .delay(for: .seconds(0.1), scheduler: DispatchQueue.main) } .scan([]) { accumulated, new in accumulated + new } .receive(on: DispatchQueue.main) .assign(to: &$items) .store(in: &cancellables) }}After: AsyncAlgorithms Merge + TaskGroup
import AsyncAlgorithms
@Observablefinal class MultiSourceLoader { @MainActor private(set) var items: [Item] = []
func loadFromAllSources() async { let sources = [ APIClient.fetchItems(from: .source1), APIClient.fetchItems(from: .source2), APIClient.fetchItems(from: .source3) ]
Task { @MainActor in for await stream in sources.map { $0.values }.merge() { for await newItems in stream { self.items.append(contentsOf: newItems) } } } }
// Alternative: Using TaskGroup for parallel execution func loadFromAllSourcesParallel() async { await withTaskGroup(of: [Item].self) { group in group.addTask { await APIClient.fetchItems(from: .source1) } group.addTask { await APIClient.fetchItems(from: .source2) } group.addTask { await APIClient.fetchItems(from: .source3) }
for await newItems in group { await MainActor.run { self.items.append(contentsOf: newItems) } } } }}Key differences:
- Combine
merge()combines publishers; AsyncAlgorithmsmerge()combines sequences - For parallel execution, use
TaskGroupinstead offlatMap - State updates can use
@MainActorinstead of.receive(on:)
Anti-Patterns to Avoid
Section titled “Anti-Patterns to Avoid”❌ Don’t Use Task.sleep for Debouncing
Section titled “❌ Don’t Use Task.sleep for Debouncing”// ❌ Bad: Manual debouncing without backpressurefunc search(_ query: String) { Task { try? await Task.sleep(for: .milliseconds(500)) await performSearch(query) }}Problem: Every keystroke spawns a new task. If user types fast, multiple tasks execute simultaneously after 500ms, causing out-of-order results and wasted API calls.
Solution: Use debounce() from AsyncAlgorithms for automatic backpressure and cancellation.
❌ Don’t Manually Combine Values
Section titled “❌ Don’t Manually Combine Values”// ❌ Bad: Manual combination without operatoractor FormValidator { private var currentUsername: String = "" private var currentEmail: String = "" private var currentPassword: String = ""
func updateUsername(_ username: String) { currentUsername = username checkForm() }
func updateEmail(_ email: String) { currentEmail = email checkForm() }
func updatePassword(_ password: String) { currentPassword = password checkForm() }
private func checkForm() { let state = validate( username: currentUsername, email: currentEmail, password: currentPassword ) // Update UI or emit validation state }}Problems:
- More state management
- Boilerplate code for each field
- Harder to add new fields
- No stream composition benefits
Solution: Use combineLatest() for cleaner, composable validation.
❌ Don’t Share Streams Without AsyncChannel
Section titled “❌ Don’t Share Streams Without AsyncChannel”// ❌ Bad: Multiple consumers sharing same streamlet stream = AsyncStream<Int> { continuation in for i in 1...10 { continuation.yield(i) } continuation.finish()}
Task { for await value in stream { print("Consumer 1: \(value)") }}
Task { for await value in stream { print("Consumer 2: \(value)") }}Problem: Values are split between consumers unpredictably. Each value goes to only one consumer.
Solution: Use AsyncChannel for true multi-consumer scenarios with backpressure.
Concurrency-Safe Notifications (iOS 26+)
Section titled “Concurrency-Safe Notifications (iOS 26+)”Swift 6.2 introduces typed, thread-safe notifications.
MainActorMessage
Section titled “MainActorMessage”For notifications that should be delivered on the main actor:
// Old wayNotificationCenter.default.addObserver( forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in self?.handleDidBecomeActive() // ⚠️ Concurrency warning}
// New way (iOS 26+)token = NotificationCenter.default.addObserver( of: UIApplication.self, for: .didBecomeActive) { [weak self] message in self?.handleDidBecomeActive() // ✅ No warning, guaranteed main actor}Key difference: Observer closure is guaranteed to run on @MainActor.
AsyncMessage
Section titled “AsyncMessage”For notifications delivered asynchronously on arbitrary isolation:
struct RecentBuildsChangedMessage: NotificationCenter.AsyncMessage { typealias Subject = [RecentBuild] let recentBuilds: Subject}
// Enable static member lookupextension NotificationCenter.MessageIdentifierwhere Self == NotificationCenter.BaseMessageIdentifier<RecentBuildsChangedMessage> { static var recentBuildsChanged: NotificationCenter.BaseMessageIdentifier<RecentBuildsChangedMessage> { .init() }}Posting:
let builds = [RecentBuild(appName: "Stock Analyzer")]let message = RecentBuildsChangedMessage(recentBuilds: builds)NotificationCenter.default.post(message)Observing:
// Old way: Unsafe castingNotificationCenter.default.addObserver(forName: .recentBuildsChanged, object: nil, queue: nil) { notification in guard let builds = notification.object as? [RecentBuild] else { return } handleBuilds(builds)}
// New way: Strongly typed, thread-safetoken = NotificationCenter.default.addObserver( of: [RecentBuild].self, for: .recentBuildsChanged) { message in handleBuilds(message.recentBuilds) // ✅ Direct access, no casting}Benefits:
- Strongly typed (no
Anycasting) - Compile-time thread safety
- Clear isolation guarantees
Common Challenges
Section titled “Common Challenges”“It’s Too Much Work”
Section titled ““It’s Too Much Work””Break it down:
- Migrate one package at a time
- Use 30-minute daily sessions
- Create checkpoints with small PRs
- Celebrate incremental progress
“My Team Isn’t Ready”
Section titled ““My Team Isn’t Ready””Start small:
- Enable Swift 6 for new files only
- Make new types
Sendableby default - Share learnings in team meetings
- Pair program on tricky migrations
“Dependencies Aren’t Ready”
Section titled ““Dependencies Aren’t Ready””Options:
- Update to latest versions first
- Use
@preconcurrencytemporarily - Contribute fixes to open-source dependencies
- Wrap third-party APIs in your own concurrency-safe layer
“I Keep Going in Circles”
Section titled ““I Keep Going in Circles””This is the “concurrency rabbit hole”:
- Take breaks and revisit later
- Temporarily disable strict checking to make progress elsewhere
- Focus on one module at a time
- Don’t try to fix everything at once
Common Mistakes Agents Make
Section titled “Common Mistakes Agents Make”- Blanket
@MainActor: Do not slap@MainActoron everything to silence errors. Ask whether the code truly needs main-actor isolation. - Mixing migration with unrelated refactors: Focus solely on concurrency changes. Architectural improvements belong in separate PRs.
- Using
@unchecked Sendableas a first response: Prefer immutable value types or actors. Reserve escape hatches for documented, temporary exceptions. - Giving pre-Swift 6.2 execution advice without checking the active feature set:
nonisolated asyncbehavior depends on whetherNonisolatedNonsendingByDefaultis enabled. - Using Approachable Concurrency without migrating feature-by-feature first: Enable individual upcoming features before the full bundle to understand each change’s impact.
Summary
Section titled “Summary”Migration to Swift 6 is a journey, not a sprint:
- Start small: Find isolated code with minimal dependencies
- Be incremental: Use the three strict concurrency levels (Minimal → Targeted → Complete)
- Use tooling: Leverage Xcode refactoring and
swift package migrate - Create checkpoints: Small, focused PRs that can be merged quickly
- Stay positive: The concurrency rabbit hole is real, but manageable with the right habits
- Think differently: Let go of the threading mindset; trust the compiler
The result is compile-time thread safety, more maintainable code, and a future-proof codebase.
Additional resources: