Passing a reference type across a Task boundary used to compile without complaint even when two threads could mutate the same instance at once. Sendable turns that into a compile-time check instead of a runtime crash you find out about later.
#The old way
final class RequestLogger {
var entries: [String] = []
func log(_ message: String) {
entries.append(message)
}
}
let logger = RequestLogger()
Task {
logger.log("request started")
}
Task {
logger.log("request finished")
}
#The new way
actor RequestLogger {
private(set) var entries: [String] = []
func log(_ message: String) {
entries.append(message)
}
}
let logger = RequestLogger()
Task {
await logger.log("request started")
}
Task {
await logger.log("request finished")
}
struct RequestMetadata: Sendable {
let path: String
let statusCode: Int
}
func record(_ metadata: RequestMetadata, using logger: RequestLogger) async {
await logger.log("\(metadata.path) -> \(metadata.statusCode)")
}
#Why it matters
- The compiler rejects
RequestLogger(the plain class) at any point it crosses into aTaskor actor boundary, catching the race before it ships - Value types with only
Sendablestored properties conform for free — most structs and enums need no extra work - Turning
RequestLoggerinto anactormakes itSendableautomatically because the actor itself serializes access - Under the Swift 6 language mode, these checks become errors instead of warnings, closing the gap between "compiles" and "safe"
#Gotcha
A class can be forced to conform with @unchecked Sendable, which tells the compiler to trust you instead of verifying anything. That's sometimes legitimate for a type that's internally synchronized in a way the compiler can't see (a lock around every mutable property, for instance), but reaching for it just to silence a warning without actually auditing the type's thread-safety reintroduces the exact race the annotation exists to prevent.