Main actor-isolated class property 'shared' can not be referenced from a nonisolated context; this is an error in the Swift 6 language mode

The author's fingers trembled slightly as he reached for the glass to take another sip of whiskey in despair, as he encountered yet another warning that would be an error in the Swift 6 language mode.

Maybe squinting at that tinted yellow line for long enough will make it disappear, or maybe not. That is the question.

Loading the Elevenlabs Text to Speech AudioNative Player...

I have stopped drinking, so keeping that hypothetical situation aside, let us see how I can deal with all the dreadful warnings. (or errors because you are courageous enough to turn strict concurrency checking to complete)

This is a series of working with the Swift 6 errors together. I am not good with Swift, so feedback is appreciated.

Initial Code

This one is a simple example of working with the cursed singleton.

class HapticsManager {
    static let shared = HapticsManager()

    private init() {}

    func playHaptic(style: UIImpactFeedbackGenerator.FeedbackStyle) {
        let generator = UIImpactFeedbackGenerator(style: style)
        generator.prepare()
        generator.impactOccurred()
    }
}

You use it like:

HapticsManager.shared.playHaptic(style: .light)

As simple as it can get.

Using Swift 6 and Complete Concurrency Check

Now, I select the particular target:

  • Go to Build Settings,
  • Then go to Swift Compiler - Upcoming Features,
  • I turn Strict Concurrency Checking to Complete.

Then I built the project in Xcode 16 and got a couple of warnings thrown right away:

Static property 'shared' is not concurrency-safe because non-'Sendable' type 'HapticsManager' may have shared mutable state

Call to main actor-isolated initializer 'init(style:)' in a synchronous nonisolated context

Call to main actor-isolated instance method 'prepare()' in a synchronous nonisolated context

Call to main actor-isolated instance method 'impactOccurred()' in a synchronous nonisolated context

I see that there are roughly two kinds of cases to handle for this.

Then, I move to the same window and change the Swift Language Version to Swift 6. Be prepared.

I have 4 errors in 11 lines of code. Am I that bad of a programmer, Swift 6?

What is Concurrency Safe?

I used a static property shared. Yep, a singleton. And my current self has no remorse for committing this sin. But it is important to understand what is going on with that error.

What does "concurrency safe" actually mean?

Concurrency safety refers to code that can be safely executed in parallel without causing data races or unexpected behaviour. When multiple parts of your program are running simultaneously, they might try to access or modify the same data at the same time. If your code is not concurrency-safe, this can lead to bugs that are difficult to reproduce and debug.

The shared instance of HapticsManager is flagged as not concurrency-safe because it is a non-Sendable type with a potentially shared mutable state.

But then, what is a Sendable type?

Sendable is a protocol in Swift that indicates whether a type is safe to send across concurrency boundaries, such as between different threads or tasks. Types that conform to Sendable are guaranteed not to have any mutable state that could be accessed from multiple threads simultaneously.

The HapticsManager class, as a non-Sendable type, does not provide these guarantees.

The Easy Way Out: @unchecked Sendable

Here is how I could "fix" the HapticsManager with @unchecked Sendable:

class HapticsManager: @unchecked Sendable {
    static let shared = HapticsManager()
    private init() {}
    
    func playHaptic(style: UIImpactFeedbackGenerator.FeedbackStyle) {
        let generator = UIImpactFeedbackGenerator(style: style)
        generator.prepare()
        generator.impactOccurred()
    }
}

The easiest way out of this mess is to slap @unchecked Sendable everywhere in the class.

This silences the compiler warning. But should I?

@unchecked Sendable tells the compiler, "Trust me, this type is safe to use across threads". But is it? Am I absolutely sure about thread-safety and cannot conform to Sendable normally? I am telling Swift to turn off the safety checks for this type. But am I so confident in my programming skills? Well, that is up for debate.

With @unchecked Sendable, the playHaptic method can be called from any thread. This could lead to UI operations on background threads, a big no-no in iOS land.

When should you use it, then? When you know what you are doing.

  1. You are 100% certain the class will never have a mutable state.
  2. You use more complex synchronisation methods that Swift's type system cannot automatically check.

But for most cases, it is better to use proper concurrency handling.

Making Sendable

After the quick and dirty solution, let's explore what else I can do. I will use Swift's concurrency features to our advantage:

final class HapticsManager: Sendable {
    static let shared = HapticsManager()
    private init() {}
    
    func playHaptic(style: UIImpactFeedbackGenerator.FeedbackStyle) {
        let generator = UIImpactFeedbackGenerator(style: style)
        generator.prepare()
        generator.impactOccurred()
    }
}

I made the class final to prevent other code from subclassing HapticsManager. Subclassing could introduce thread-safety issues that I cannot control, so making it final helps maintain guarantees about concurrency safety. And I doubt I will subclass it anyway.

By conforming to Sendable, I am telling Swift that this type is safe to use across different threads or tasks. Swift will check that our class meets the requirements for Sendable.

The class has no stored properties (other than the static shared instance). This lack of mutable state allows me to conform to Sendable without additional work. Sounds good for the lazy me!

By doing it this way, I tried addressing the underlying concurrency concerns. I am telling Swift exactly how the class should behave in a multi-threaded environment and letting the compiler do the rest of the work.

This is a simple example; you may inevitably face problems with different properties and methods. This approach requires more thought than just slapping @unchecked Sendable, but it results in safer code—an investment to prevent those ugly, hard-to-debug concurrency issues down the line.

Bringing the Main Actor

If I open the definition of the UIImpactFeedbackGenerator class, I see that it is annotated with @MainActor:

@MainActor open class UIImpactFeedbackGenerator : UIFeedbackGenerator {

When a class is marked with @MainActor, it does not mean its properties and methods can only be accessed from the main thread. Rather, it means they are isolated to the main actor.

The main actor typically corresponds to the main thread, but the key difference is in how you can access these properties and methods:

  1. You can access them synchronously if you are already in a main actor context.
  2. You can access them asynchronously from any thread or task using await.

So, a more accurate translation would be: "All the class's properties and methods are isolated to the main actor, ensuring they are accessed in a thread-safe manner, typically on the main thread."

This means you can still call methods of a @MainActor class from background threads or other contexts, but you need to use await to do so. Swift will ensure that the actual execution happens on the main actor.

Given that UIImpactFeedbackGenerator is marked with @MainActor, I use await when calling its methods from a non-main actor context:

final class HapticsManager: Sendable {
    static let shared = HapticsManager()
    private init() {}

    func playHaptic(style: UIImpactFeedbackGenerator.FeedbackStyle) async {
        let generator = await UIImpactFeedbackGenerator(style: style)
        await generator.prepare()
        await generator.impactOccurred()
    }
}

I made playHaptic an async function by adding the async keyword and added await before creating the UIImpactFeedbackGenerator instance and before calling prepare() and impactOccurred().

These await keywords are necessary because UIImpactFeedbackGenerator is `@MainActor`-isolated. When I call these methods from a potentially non-main actor context, we must explicitly await to ensure they are executed on the main actor.

Now, to use this updated HapticsManager, I call it from an asynchronous context:

func triggerHapticFeedback() {
    Task {
        await HapticsManager.shared.playHaptic(style: .light)
    }
}

This means that even if triggerHapticFeedback is called from a background thread, the actual haptic feedback operations will be correctly executed on the main actor.

By using await, I am telling Swift, "Hey, this operation might need to switch to the main actor. Please handle that for me." Swift slowly turns into a saviour.

Wait.

Instead of adding await to every method call, I can simplify things by adding @MainActor to the method:

final class HapticsManager: Sendable {
    static let shared = HapticsManager()
    private init() {}

    @MainActor
    func playHaptic(style: UIImpactFeedbackGenerator.FeedbackStyle) {
        let generator = UIImpactFeedbackGenerator(style: style)
        generator.prepare()
        generator.impactOccurred()
    }
}

This approach helps me isolate only the method that needs to run on the main actor rather than the entire class. I can put it on the whole class, too, for this example, but I prefer a targeted approach for the future, as other methods in the class (if we add any) can run on any thread unless specifically marked.

Now, using it becomes straightforward from a SwiftUI view:

onAppear {
    HapticsManager.shared.playHaptic(style: .heavy)
}

When you are unsure if you are on the main actor, use this approach:

func triggerFeedback() {
    Task { @MainActor in
        HapticsManager.shared.playHaptic(style: .light)
    }
}

The Task initialiser creates a new asynchronous task, and the @MainActor in part tells Swift that the code inside this closure should run on the main actor (typically the main thread). The reason for this is because the playHaptic method is marked with @MainActor.

Inside the task, I call HapticsManager.shared.playHaptic(style: .light). This will always run on the main actor.

Alternatively, if you know triggerFeedback() will always be called from a main actor context, you could simplify it to:

@MainActor
func triggerFeedback() {
    HapticsManager.shared.playHaptic(style: .light)
}

Moving Foward

Staring at this error-free Swift code gives me some sort of gratification, even if it is a sample code. From the initial despairs, I learned a lot about Swift 6 of seeing those dreaded warnings while writing this post.

I love to take the easy way out, but not anymore. I will face these errors head-on and give the compiler the validation it deserves for making my code safer.

There are many such errors to be dealt with, and I will continue posting about them. Until then, believe in yourself, especially those who, like me, are not "good with Swift" (yet!).

I provided this blog post to Claude to write the excerpt, and this line is too amusing not to include:

And remember, every time you successfully make something Sendable, a Swift angel gets its wings.

Happy Swifting!

Astro Affiliate.

Astro (Affiliate)

Find the right keywords for your app and climb the App Store rankings. Improve your app visibility and increase your revenue with Astro. The first App Store Optimization tool designed for indie developers!

Tagged in: