Context
I recently encountered a subtle protocol conformance issue when adding a gRPC interceptor in a package enabled with NonisolatedNonsendingByDefault feature flag. I'm sharing this to see if others have faced similar issues and to discuss how we might improve the developer experience for such "invisible" mismatches.
The Problem
When NonisolatedNonsendingByDefault is enabled in a target, the compiler automatically infers closure parameters in protocol implementations as nonisolated(nonsending). This led to a cryptic conformance failure when implementing a protocol from a library (in this case, grpc-swift-2) that was likely compiled without this feature enabled.
Minimal Reproducible Example
// Target has .enableUpcomingFeature("NonisolatedNonsendingByDefault")
struct LoggingInterceptor: ClientInterceptor {
func intercept<Input, Output>(
request: StreamingClientRequest<Input>,
context: ClientContext,
next: (StreamingClientRequest<Input>, ClientContext) async throws -> StreamingClientResponse<Output>
) async throws -> StreamingClientResponse<Output> where Input: Sendable, Output: Sendable {
// Inference: 'next' is treated as nonisolated(nonsending)
try await next(request, context)
}
}
// gRPC Swift 2's ClientInterceptor
public protocol ClientInterceptor: Sendable {
func intercept<Input: Sendable, Output: Sendable>(
request: StreamingClientRequest<Input>,
context: ClientContext,
next: (
_ request: StreamingClientRequest<Input>,
_ context: ClientContext
) async throws -> StreamingClientResponse<Output>
) async throws -> StreamingClientResponse<Output>
}
The Compiler Error:
error: type 'LoggingInterceptor' does not conform to protocol 'ClientInterceptor'
note: candidate has non-matching type '... next: nonisolated(nonsending) (StreamingClientRequest<Input>, ClientContext) async throws -> ...'
note: protocol requires function 'intercept' with type '... next: (StreamingClientRequest<Input>, ClientContext) async throws -> ...'
The Root Cause & Fix
The gap lies in the invisible inference.
-
The library's protocol defines the closure as a standard
@concurrentclosure without these modern isolation markers. -
The implementing target, however, "upgrades" the signature by adding
nonisolated(nonsending)during inference, causing a signature mismatch.
The fix was to explicitly add @concurrent to the closure to match the protocol's expected signature:
next: @concurrent (StreamingClientRequest<Input>, ClientContext) async throws -> StreamingClientResponse<Output>
Questions for Discussion
-
Diagnostic Clarity: The current
note: candidate has non-matching typeis technically accurate but fails to highlight why the inference changed. Could the compiler explicitly link@concurrent? -
Library Future-proofing: How should library authors define protocols to be resilient against these inference changes? Should attributes like
@Sendablebe applied more aggressively in library headers?
I would love to hear thoughts from the community on how we can make this migration path smoother for developers who might spend hours debugging "invisible" code differences.