Isolate just enough: the lazy var trick with MainActor
One limitation with using @MainActor is that if you declare a class property like:
@MainActor let updater = UIUpdater()
Then the compiler forces the class's init to be actor-isolated as well:
@MainActor init() { ... }
This can be problematic, especially in classes without async methods or when you can't fully isolate a dependency in @MainActor without also isolating other classes that use it — propagating the isolation throughout the app.
A practical solution is to declare the property as lazy var. This delays its initialization until accessed, and only the method that uses it needs to be within MainActor context:
class ViewModel {
@MainActor lazy var updater = UIUpdater() func init() {} @MainActor
func updateUI() {
updater.showResult()
}
}
This keeps the architecture clean and minimizes unnecessary actor annotations.
When you can’t rewrite everything: @unchecked Sendable to the rescue
In many projects, there are classes that already handle concurrent access using DispatchQueue, NSLock, or OperationQueue. These classes often have shared mutable properties like caches, services, or delegates, and adapting them to Sendable may be nearly impossible without rewriting much of the code.
This is where @unchecked Sendable comes in: a way to tell the compiler “trust me, I’ll make it safe.” It doesn't guarantee thread safety but allows compilation when you know you're already handling access correctly:
final class SessionManager:
@unchecked Sendable {
private let syncQueue = DispatchQueue(label: "SessionManagerQueue")
private var sessions: [String: Session] = [:] func save(_ session: Session) {
syncQueue.async {
self.sessions[session.id] = session
}
}
}
This type of code is common in many apps. Instead of redesigning everything to be automatically safe with Sendable, you can document and mark it as @unchecked Sendable, retaining manual synchronization control and ensuring concurrency.
Gradual migration with @preconcurrency
When working on an app split into multiple modules or frameworks, migrating everything at once to the new strict concurrency model is unfeasible — the error list may be endless. This is where @preconcurrency becomes essential.
This attribute lets you declare that a protocol, type, or import is meant to be used with code that doesn't yet follow the strict concurrency rules. By applying it, you avoid the compiler forcing you to annotate everything with Sendable or @MainActor all at once.
For example, you can mark a framework import that hasn’t been migrated yet:
@preconcurrency import LegacyNetworking
You can also use it in protocols used from new classes but not yet fully migrated:
@preconcurrency
protocol LegacyDelegate: AnyObject {
func didFinish()
}
This is especially useful if you have classes that are @MainActor but must conform to legacy protocols. Without @preconcurrency, the compiler would force you to annotate the entire protocol as @MainActor too, which may not be viable yet.
Another common strategy is to use @preconcurrency in extensions that implement these protocols:
extension ViewController:
@preconcurrency LegacyDelegate {
@MainActor func didFinish() {
// running on MainActor
}
}
This way, the compiler allows implementation within a @MainActor context, even if the protocol isn’t.
In short, @preconcurrency is key for layered migration: you can migrate module by module without breaking anything, gradually ensuring concurrency.
XCTest and the limits with MainActor
One of the biggest obstacles when migrating to strict concurrency is the traditional testing system. XCTest was designed long before actors, and this causes serious conflicts when working with types annotated with @MainActor.
Common XCTest issues
Some frequent limitations:
. You can’t annotate setUp() or tearDown() with @MainActor because they override XCTestCase methods.
. You can’t access @MainActor properties from those methods or create instances requiring it.
. You can’t use await inside setUp() unless you use hacks like Task.runDetached or expectation.
Enter Swift Testing: built for modern concurrency
With Swift 6 and the new Swift Testing framework, we solve these issues:
. You can mark the entire test or setUp() with @MainActor, no hacks needed.
. You can use async let and await natively throughout the test phases.
. You can define tests as async functions, without expectation or wait().
Example:
@MainActor
@testable import MyApp struct MyTests: Test {
@MainActor var updater: UIUpdater @MainActor
func setUp() {
updater = UIUpdater()
} func testButtonTitle() async {
await updater.load()
XCTAssertEqual(updater.title, "Welcome")
}
}
This is not only cleaner: the compiler can verify isolation rules from the start, avoiding inconsistencies. Being part of the new model, Swift Testing supports native integration with @MainActor, Sendable, isolated, and other Swift Concurrency constraints.
Conclusions: a strict path toward a safer (and more flexible?) future
Apple has gone all-in on safe concurrency in Swift 6. With the introduction of Sendable, MainActor, and strict compiler enforcement, the language has become one of the strictest in catching errors at compile time. While this is beneficial long-term, migrating real projects has become a complex, inflexible, and often frustrating task.
Tools like @unchecked Sendable, @preconcurrency, or even MainActor.assumeIsolated exist as temporary patches to make progress without rewriting entire codebases. But they are reactive solutions, often hard to discover, for a transition that many teams can’t tackle globally.
Swift 6 was designed as if we started from a perfect base: no shared mutability, no controllers, no singletons. But reality is different, especially in apps with years of history.
The future? Likely to be gentler.
Based on Swift 6’s evolution, it’s likely the future will adopt a reverse philosophy: instead of assuming everything is concurrent and isolated, it will be assumed that UI code runs on the main thread unless stated otherwise.
This would greatly reduce the burden of code annotation, make test writing more natural, and bring Swift closer to what other languages already allow with less friction.
In the meantime, the road is demanding, but worth it. The rewards — more stable apps, fewer concurrency bugs, and better tools to ensure safety — are worth the effort. We just need Apple to also consider the teams still walking, not just those already running.