SendablePublishers: Combine for Swift 6 strict concurrency

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 Combine disables the checks – project-wide.
  • AsyncStream abandons Combine's operators and backpressure model.
  • Hand-rolled @unchecked Sendable wrappers, 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

  1. SendablePublisher<Output, Failure> – a typealias for Publisher<Output: Sendable, Failure> & Sendable. A type for your signatures.
  2. SendableShell<Upstream> – a thin Sendable wrapper that preserves the upstream type. No erasure, no runtime cost.
  3. Retroactive conformancePassthroughSubject / CurrentValueSubject become Sendable where Output: 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 Sendable conformance 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.

5 Likes