Swift 6's strict concurrency checking is one of the best things to happen to Apple's platforms in years, but disruptive, if your codebase is built on Combine.
Combine has a problem when used in Swift 6 mode: Publisher, AnyPublisher, PassthroughSubject – none of them are Sendable. Cross an actor boundary, store one in a Sendable type, capture one in a @Sendable closure, and the compiler stops you.
The usual workarounds cost something:
@preconcurrency import Combinedisables the checks – project-wide.AsyncStreamabandons Combine's operators and backpressure model.- Hand-rolled
@unchecked Sendablewrappers, re-written in every project that hits this.
SendablePublishers closes the gap in one dependency – and the dependency is easy to justify.
- Tiny. The whole library is ~68 KB in release and adds roughly 1 s to a clean build on an M1 Pro.
- Zero learning curve. It's still Combine: the same operators, the same semantics, the same backpressure. Only the types become
Sendable. No conversion to async/await, no new mental model. - Compile-time safety. Every operator closure is
@Sendable. Data-race bugs become errors while you type, not crashes at 2 a.m. - Cheap to adopt, easy to leave. Wrap a subject in one line; the rest of your code stays as is. Erase to
AnySendablePublisher<Output, Failure>when you want to hide the pipeline.
What it is
SendablePublisher<Output, Failure>– a typealias forPublisher<Output: Sendable, Failure> & Sendable. A type for your signatures.SendableShell<Upstream>– a thinSendablewrapper that preserves the upstream type. No erasure, no runtime cost.- Retroactive conformance –
PassthroughSubject/CurrentValueSubjectbecomeSendablewhereOutput: Sendable, grounded in Combine's own thread-safe implementations.
The code that failed before now compiles after one import:
import Combine
import SendablePublishers
let subject = PassthroughSubject<Int, Never>() // now Sendable
let events = subject.asSendablePublisher() // SendableShell<...>, also Sendable
Task { @MainActor in
subject.send(1)
}
A realistic shape – a service hands a Sendable publisher to an actor-isolated view model:
@MainActor
final class MapViewModel {
var statusText = ""
var cancellables = Set<AnyCancellable>()
init(locationService: LocationService) {
locationService.coordinates
.filter { $0.latitude != 0 }
.debounce(for: .milliseconds(200), scheduler: RunLoop.main)
.map { "\($0.latitude), \($0.longitude)" }
.assign(to: \.statusText, on: self)
.store(in: &cancellables)
}
}
Not a dead end, not a fork. The built-in operator surface is not the ceiling. An SPI (@_spi(ExtensionsUnsafeAPI)) exposes unverified_SendablePublisher and _upstream, so you can wrap your own thread-safe publishers and add your own operators in your own module. Your project needs something extra? Add it – no fork required.
@_spi(ExtensionsUnsafeAPI) import SendablePublishers
extension SendableShell {
func toggle() -> SendableShell<Publishers.Map<Upstream, Bool>> {
let mapped = _upstream.map { !$0 }
return SendableShell<Publishers.Map<Upstream, Bool>>(unverified_SendablePublisher: mapped)
}
}
Design
- Wrapper, not a rewrite – runtime behavior is Combine's; there is no custom subscription machinery.
- The
@unchecked Sendableconformance isn't verified by the compiler – Swift can't see Combine's internal locks. It's trust in Apple's implementation, stated explicitly. - Preserved concrete types mean long chains produce long type names; erase when that matters.
Installation
.package(url: "https://github.com/iDmitriyy/SendablePublishers.git", branch: "main")
Repo: github.com/iDmitriyy/SendablePublishers – the README lists about 94 wrapped operators across creation, combining, error handling, time, scheduling, buffering, sharing.
Status: active development; the operator surface tracks Combine's. Feedback and contributions are welcome.
Future Directions
The library today solves the Sendability layer – the compile walls, captures, signatures, and @preconcurrency escape hatches. The runtime side is the roadmap.
Next up: Driver and Signal traits, inspired by RxSwift's Drive/Signal but Sendable by construction – hot, shared streams whose sink always delivers on the main thread, with drive(receiveValue:) (replays the current value on subscribe) and emit(receiveValue:) (no replay), both have @MainActor closure. That's what closes the "Publishing changes from background threads" gap.
Also planned: CancellationBag – a ~Copyable, Sendable replacement for the usual Set<AnyCancellable>. A class instance owns the bag; you insert an AnyCancellable or any Cancellable from any thread and never think about it again. Because the bag is a stored property of its owner, it lives and dies with the owner – on deinit it cancels everything it holds. So the answer to "who cancels this, and when" is always the same: the bag does, when its owner goes away. (A Sendable conformance on AnyCancellable alone would only satisfy the compiler; it wouldn't give you this.)
And: AsyncAlgorithms interop. A Sendable-clean bridge to swift-async-algorithms. The goal: let SendablePublisher and async algorithms trade streams without hitting the Sendable wall – feed a publisher's output into a Channel/Buffer, or consume an async sequence as a Sendable publisher – so the two operator sets compose instead of competing. This replaces the earlier vague "AsyncSequence bridge" idea with a concrete target.
The operator surface continues to track Combine's.
Open to feedback on all of the above.