In the previous post, we looked at running work in parallel with async let and TaskGroup. But once tasks start running in parallel, there’s a bigger question: what happens to the data they share? Swift’s answer is the Sendable protocol.

What is Sendable?

Sendable is a marker protocol that tells the compiler a type can safely be passed across concurrency domains like tasks and actors. If a type isn’t Sendable, Swift will stop you from moving it into a concurrent context because that might cause a data race. The compiler does a lot of this checking automatically, but sometimes you’ll need to be explicit.

Value Types

Immutable value types are generally safe. For example:

1
2
3
public struct Company: Sendable {
    let name: String
}

Because Company is a struct with immutable properties, it’s automatically safe to send between tasks.

Enums made up of other Sendable types also conform without extra work. Take this example:

1
2
3
4
5
public enum NetworkResponse: Sendable {
    case success(data: String)
    case failure(errorCode: Int)
    case empty
}

Each associated value here (String and Int) is already Sendable, so the entire enum is safe to move across concurrency boundaries. Reference types are more complicated. Take this example:

1
2
3
4
class Article {
    var title: String
    init(title: String) { self.title = title }
}

This is not Sendable. Two tasks could read and write title at the same time, leading to undefined behaviour. If you try to pass it into a TaskGroup, the compiler will complain.

Not all classes are excluded, though. A final class that is completely immutable can conform to Sendable safely. For example:

1
2
3
4
5
6
7
8
9
final class ImmutableUser: Sendable {
    let id: Int
    let name: String
    
    init(id: Int, name: String) {
        self.id = id
        self.name = name
    }
}

Because ImmutableUser is final (so it can’t be subclassed) and all its properties are immutable and Sendable, this class is safe to share across concurrency domains. If you do have a mutable class, there are two common fixes. The first is to make the type immutable:

1
2
3
struct Article: Sendable {
    let title: String
}

The second is to isolate mutation using an actor:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
actor ArticleStore {
    private var articles: [Article] = []
    
    func add(_ article: Article) {
        articles.append(article)
    }
    
    func all() -> [Article] {
        articles
    }
}

The actor guarantees that only one task can access articles at a time, so Sendable conformance is no longer needed. You’ll see Sendable complaints when trying to move a non-Sendable type across concurrency domains. For example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func process(articles: [Article]) async {
    await withTaskGroup(of: Void.self) { group in
        for article in articles {
            group.addTask {
                // ❌ Error: 'Article' is not Sendable
                print(article.title)
            }
        }
    }
}

The compiler stops you here because Article isn’t safe to send into the child tasks.

nonisolated

Another place this shows up is when you mark an actor method as nonisolated. Once you do that, the compiler no longer guarantees access through the actor’s queue. Any values crossing into that method need to be Sendable.

1
2
3
4
5
6
7
8
actor ContactsStore {
    private(set) var contacts: [Contact] = []
    
    nonisolated func printContacts(_ contacts: [Contact]) {
        // Contacts must be Sendable here
        print(contacts)
    }
}