December 17, 2025
A while ago, I wrote a post about non-sendable types. Then, much later, I talked about them again in a post about protocols. In fact, I didn’t just talk about them, I teased an whole design approach based on non-sendable types. It’s hard to name things like this without sounding like you take yourself too seriously. I did try, but I couldn’t come up with anything better. It’s "Non-Sendable First Design".
But, I really am genuinely excited about it! Almost to the point of embarrassment.
In fact, I’ve been excited about non-sendable types for years at this point. Unfortunately, because of some language behavior, I thought they would be unappealing …
December 17, 2025
A while ago, I wrote a post about non-sendable types. Then, much later, I talked about them again in a post about protocols. In fact, I didn’t just talk about them, I teased an whole design approach based on non-sendable types. It’s hard to name things like this without sounding like you take yourself too seriously. I did try, but I couldn’t come up with anything better. It’s "Non-Sendable First Design".
But, I really am genuinely excited about it! Almost to the point of embarrassment.
In fact, I’ve been excited about non-sendable types for years at this point. Unfortunately, because of some language behavior, I thought they would be unappealing to most users. They were really useful, but they were also confusing and had pretty terrible ergonomics.
And then, to my surprise and delight, we got NonisolatedNonsendingByDefault (AKA "Approchable Concurrency"). This changed everything! Suddenly, non-sendables started feeling downright natural to use.
I think that the non-sendable type’s time has finally come. To me, they really feel like "the way".
Quick Refresher
Before we get into all this, I want to just take a moment to review some important concepts.
Swift’s solution to data races is to guarantee, at compile time, protection of thread-unsafe data. Swift has an abstraction to model all possible ways to you could implement this protection. This abstraction is called "isolation". The actual implementation might be a lock, a queue, or even a single dedicated thread. It’s the role of an actor to implement the protection mechanism at runtime.
The compiler does not know how specific actors work. It does not know that the MainActor actually implements its protection by funnelling all work over to the main thread. It just knows "actors protect stuff".
What stuff exactly? Well, Swift divides up all data into two gigantic groups. One is inherently safe to use anywhere, at any time, from any context. That’s the group of Sendable types. These things are completely thread-safe and they don’t need any protection. This makes them very straight-forward to use with concurrency.
The other group is the non-Sendable types. Shared, mutable data. The stuff that makes up, often, the most interesting aspects of our programs. These things are not thread-safe.
Many people think of actors as a tool you use to run stuff "in the background". But that’s actually just a (convenient, perhaps) side-effect of certain actors. This concept is fundmental to how the concurrency system is designed. The purpose of an actor is to protect data from races.
Getting Stuck
All the protection in the world doesn’t do much good if you can easily circumvent it. And this is a very interesting property of Swift Concurrency. If an actor owns a a non-sendable type, often by creating it, it gets stuck.
In fact, it could be an entire system. A whole network of interconnected classes and protocols, all working together. Just regular old types. You can do pretty much anything with these types. But the one thing the compiler will not let you do is share them in an unsafe way.
This makes non-sendable types surprisingly powerful and flexible.
The Pros and Cons
I had an incredibly difficult time deciding how to present all this. I experimented with a few radically-different options, and I just hated all of them. In the end, I’m going with the simplest thing I that I can think of. I’m just going to start by showing you all of the strengths of non-Sendable types. And then, I’m going to show you the weaknesses.
I do want to note that I’m using a default of nonisolated here.
Strength #1: simplicity
Here’s a regular old class. To keep things interesting, we’ll let it have some state. As a treat.
class Counter {
var state = 0
func reset() {
self.state = 0
}
}
Look at this! It’s just a regular old class with a single synchronous function. How could we get simpler than that?
Let’s just keep going!
class Counter {
// ...
func toggle() async {
self.state += 1
}
}
extension Counter: Equatable {
static func == (lhs: Counter, rhs: Counter) -> Bool {
lhs.state == rhs.state
}
}
There are two additions here. First, I added an asynchronous method. That might look normal to you too, but without NonisolatedNonsendingByDefault, nonisolated + async meant background. But this is non-sendable type! It cannot leave the protection of an actor to even get to the background. This catch 22 made async methods on non-sendable types extremely hard to actually use.
Luckily, we do have NonisolatedNonsendingByDefault which makes this a non-issue. And that’s great news, because async methods open up huge amount of functionality here.
The second thing I did was add a protocol conformance. Thankfully, this actually is normal! Nothing tricky is going on. But to understand why this is interesting, you have to realize that this is not the case for isolated types. If this type was MainActor, it would become impossible to conform to Equatable without something like an isolated conformance.
Now, it is true that the isolated conformance feature can become implicit, via InferIsolatedConformances. And while that is very useful, the essential complexity hasn’t gone away. The compiler has just gotten smart enough to hide it from you provided your usage is compatible.
Here is the key take away: this kind of type has a simplicity that an isolated type does not.
(You can read more about this protocol behavior in that post on protocols I mentioned if you want to understand more about the issues here.)
Strength #2: generality
This is something that takes a moment to realize. It all has to do with synchronous access.
If a type is isolated you can only have synchronous access when the isolation matches. A @MainActor type can be synchronously accessed, only, by other @MainActor things. Let’s take a look at how this affects clients.
actor ActorClient {
private let counter = Counter()
func accessSynchronously() {
counter.reset() <- this is possible!
}
}
This actor can access counter synchronously! But, if counter were isolated, perhaps with @MainActor or as an actor itself, this would not be possible.
A non-sendable type is very general. Its interface remains the same, regardless of the actor that owns it.
Weakness #1: Starting Tasks
This next topic was actually the thing that led me to thinking about non-sendable types in the first place. Take a look at this:
class Counter {
// ...
func printStateEventually() {
Task {
error: Passing closure as a 'sending' parameter...
print(self.state)
}
}
}
This code actually does not compile. To understand why, let’s look at a fairly literal translation to GCD. I do this frequently, because the two systems have very similar concepts.
class Counter {
// ...
func printStateEventually() {
DispatchQueue.global().async {
warning: Capture of 'self' with non-Sendable type 'Counter' in a '@Sendable' closure
print(self.state)
}
}
}
We are accessing self here on some background queue. Why is it a background queue? Because with Swift’s concurrency system, "what queue" is defined by the type system. And here, we have a nonisolated function, which is means "the queue is not defined".
It is not possible to guarantee thread safety without more work here.
Queue management is very important when working with thread-unsafe types. It is also interesting that this code does compile, but produces a warning. That’s because all of the Dispatch APIs have been annotated with @preconcurrency, which downgrades concurrency errors into warnings.
A Solution to Starting Tasks
This problem kicked off a lot of research on my part. At the time, I was thinking about it purely from a "how the heck do I make the compiler happy" perspective. But, actually, there’s more to it that this.
You know when someone says "well actually you shouldn’t be doing that in the first place". Often, this isn’t helpful. Because making use of this information, even if correct, means you need to re-design things. Things that might be out of your control. Or, that you just don’t feel like doing.
Yeah. Well.
With GCD, to handle this problem, we need to return to the same queue that we started on. This typically forces us to either use a statically-defined queue, like main, or grab a queue and store it as an instance variable. Both of these patterns are super-common in GCD-based code.
A global actor is, in many ways, just a statically-defined queue that the compiler makes sure you cannot forget to use. And using MainActor here does work. But, it’s annoying! Because there might be nothing "main thread" about this thing. We have to sacrifice generality.
A queue instance variable, on the other hand, is pretty interesting. Except Swift’s concurrency system doesn’t have anything exactly like that.
class Counter {
// ...
func printStateEventually(isolation: isolated any Actor) {
Task {
_ = isolation ???
print(self.state)
}
}
}
Swift does have a way to capture "where you were" so that you can get back. It is an isolated parameter. You can use them to solve this problem, but they come with three downsides.
First, it burdens the caller with supplying the owning actor. What if the caller doesn’t have that information? Then it would, itself, need an isolated parameter. These are viral!
Second, due to a very long-standing quirk with how isolation inheritance works, you must capture the parameter inside the Task body to make it all work. Thankfully, this is a well-known pain point and I have confidence this will eventually just be fixed. There has already been some work done here.
But if all that doesn’t turn you off, there’s another, deeper problem.
Starting Tasks is Fraught
Remember when I said that people told me I should not be creating new unstructured tasks within a type like this? Well, it annoys me to say it, but I think they are right.
At first, I found it very frustrating that the language was making this so difficult. But, I’ve now come around. I actually think this is an example of syntactic salt. Yes, it is possible, but actively discouraging people from doing it makes sense.
What you should actually do is just make this an async method. Stay in the structured concurrency world. And, if for some reason that’s a problem for the caller, they can make the Task.
So, after all that, is this really a weakness? I think, ultimately, it is appropriately difficult.
(June also noticed and wrote about a similar phenomenon you might be interested in. Wrapping Task creation is tricky business.)
Weakness #2: "weird properties"
Swift has a number of ways to define, for lack of a better term, weird properties. Things like property wrappers, macros, and even lazy vars. These make it possible to create situations that are extremely difficult to support if the properties themselves have complex concurrency requirements.
class Counter {
lazy var internal = {
SpecialCounter(isolatedParam: ???)
}()
}
That’s a big "if", but it’s at least something to be aware of. I discovered this while building a real system. These situations are quite rare, and it could be this is another of those "just don’t do that" kinds of things. But, if you go deep with this stuff, it could come up. Fair warning.
"First" for a Reason
This idea all is about a place to start. And I think it’s very fitting to start with the simplest and more "regular" kind of thing: nonisolated, thread-unsafe types.
Now, I know, for a fact, someone read nonisolated there and immediately started thinking "lol, simple". But I want to stop you right there.
Before Swift 5.5 came along, there was no such thing as isolation. Literally every type, global variable, and all other declarations you can think of was nonisolated. Including from Objective-C and C.
"Nonisolated" is normal. It’s just an unfamiliar concept.
But, as soon as you run into even a tiny bit of concurrency, things can change. And when that happens, maybe a non-sendable type is no longer the right choice. Sometimes, this is obvious. Having trouble accessing MainActor stuff? Maybe your type needs to be MainActor too. Trying to kick asynchronous work without context? This is hard for good reason, so think about it.
But there are so many situations where a plain old non-sendable type is a perfect fit. In fact, now that we have NonisolatedNonsendingByDefault, the number of types that truly need to be MainActor is lower than ever. Even traditionally-MainActor things like ObservableObject and @Observable can sometimes avoid it.
Isolation, don’t forget, is a constraint. Adding it, either explicitly or implicitly as a default changes the nature of the type in a profound way. The constraint can have considerable implications, particularly when protocols and subclassing are involved.
Side-stepping the problem entirely, with a simpler type no less, almost feels like cheating.
And that’s why I’d recommend starting here. If it starts getting hard or confusing or you don’t like it for some reason, just move on. It won’t work for every situation. But when it does, it’s really nice.
Did you know that I do consulting for concurrency and Swift 6 migrations? If you think I could help, get in touch.