Pitch: `Continuation` — Safe and Performant Async Continuations

@ktoso and I would like to pitch a new Continuation type for the stdlib.

You can view the latest and full draft as a Swift evolution PR.

Continuation — Safe and Performant Async Continuations

Summary

We propose Continuation<Success, Failure>, a noncopyable continuation type that makes double-resume a compile-time error and missing-resume a guaranteed runtime trap, with no allocations and no atomic operations on the fast path. By leveraging move-only semantics and consuming methods, Continuation closes the safety gap between UnsafeContinuation and CheckedContinuation without paying for runtime checks.

Motivation

Continuations are the primary mechanism for bridging callback-based APIs into Swift's structured concurrency world. Today, developers face an uncomfortable choice between two options:

  • UnsafeContinuation — Zero overhead, but resuming twice is undefined behavior and forgetting to resume silently leaks the awaiting task forever. Neither mistake produces a diagnostic.
  • CheckedContinuation — Adds runtime bookkeeping to detect these mistakes.

The type system should help

Swift has a strong tradition of using the type system to prevent entire categories of bugs at compile time.

Continuation misuse should be in this category. A continuation is a use-exactly-once value: it must be resumed exactly once, and any deviation is a bug. This is precisely the contract that move-only types enforce.

Furthermore, the recent landing of new collection types that allow noncopyable Elements in swift-collections demonstrates that the ecosystem is ready to work with noncopyable types as first-class building blocks.

Proposed Solution

We introduce Continuation<Success: ~Copyable, Failure: Error>, a ~Copyable struct that wraps UnsafeContinuation and enforces correct usage through three complementary mechanisms:

  1. Move-only semantics (~Copyable): The continuation cannot be copied, so it is impossible to resume it from two different code paths. Attempting to use a continuation after it has been moved is a compile-time error.

  2. consuming methods: Every resume method consumes self, meaning the continuation is moved into the call and becomes unavailable afterward. A second call to resume on the same binding does not compile.

  3. deinit trap with discard self: If a Continuation is dropped without being resumed, its deinit calls fatalError. The resume methods use discard self to suppress the deinit on the success path, so correctly used continuations incur no overhead.

Double-resume — fixed

actor LegacyBridge {
    var continuation: Continuation<String, Never>?

    func store(_ continuation: consuming Continuation<String, Never>) {
        self.continuation = consume continuation
    }

    func complete(with value: String) {
        if let continuation {
            continuation.resume(returning: value) // ✅ consumes continuation

            continuation.resume(returning: value) // ❌ compile error:
                                                  // 'continuation' used after consuming use
        }
    }
}

The move-only semantics prevent the continuation from being resumed twice — the second call is a compile-time error because continuation was already consumed.

Missing-resume — caught at runtime

actor LegacyBridge {
    var continuation: Continuation<String, any Error>?

    func store(_ continuation: consuming Continuation<String, any Error>) {
        self.continuation = consume continuation
    }

    func cancel() {
        // Bug: forgets to resume the continuation before clearing it.
        self.continuation = nil // 💥 runtime trap: "This continuation was dropped."
    }
}

When the stored continuation is overwritten or the actor is deallocated without resuming the continuation, the deinit fires immediately with a clear diagnostic — turning a silent hang into a diagnosable crash.

Detailed Design

Continuation struct

@frozen
public struct Continuation<Success: ~Copyable, Failure: Error>: ~Copyable, Sendable {

    @usableFromInline
    let unsafeContinuation: UnsafeContinuation<Success, Failure>
    @usableFromInline
    let file: StaticString
    @usableFromInline
    let line: Int

    @inlinable
    init(_ unsafeContinuation: UnsafeContinuation<Success, Failure>, file: StaticString, line: Int) {
        self.unsafeContinuation = unsafeContinuation
        self.file = file
        self.line = line
    }

    deinit {
        fatalError("The continuation created in \(self.file):\(self.line) was dropped.")
    }

    @inlinable
    public consuming func resume() where Success == Void {
        self.unsafeContinuation.resume()
        discard self // prevent deinit
    }

    @inlinable
    public consuming func resume(returning value: consuming sending Success) {
        self.unsafeContinuation.resume(returning: value)
        discard self // prevent deinit
    }

    @inlinable
    public consuming func resume(throwing error: Failure) {
        self.unsafeContinuation.resume(throwing: error)
        discard self // prevent deinit
    }

    @inlinable
    public consuming func resume(with result: consuming sending Result<Success, Failure>) {
        self.unsafeContinuation.resume(with: result)
        discard self // prevent deinit
    }
}

Key design points:

  • SendableContinuation is Sendable because UnsafeContinuation is Sendable. This allows passing the continuation across isolation boundaries, which is essential for bridging callbacks from other threads.
  • consuming — Each resume method consumes self, transferring ownership into the method and preventing subsequent use.
  • sending Success — The value parameter is marked sending, matching the semantics of UnsafeContinuation.resume(returning:) and enabling safe transfer of non-Sendable values into the async task.
  • consuming Success — The value parameter allows the use of noncopyable types.
  • discard self — Suppresses the deinit on the success path. This is critical: without it, every successful resume would trigger fatalError. The discard self statement tells the compiler that the value has been fully consumed and no cleanup is needed.
  • deinit — Acts as the safety net for the missing-resume case. If control flow drops a Continuation without calling resume, the deinit fires and traps immediately with a clear diagnostic message.

Free function

public nonisolated(nonsending) func withContinuation<Success: ~Copyable, Failure: Error>(
    of: Success.Type,
    throws: Failure.Type = Never.self,
    file: StaticString = #file,
    line: Int = #line,
    _ body: (consuming Continuation<Success, Failure>) -> Void
) async throws(Failure) -> Success {
    await withUnsafeContinuation { (continuation: UnsafeContinuation<Success, Failure>) in
        body(Continuation(continuation, file: file, line: line))
    }
} func withContinuation<Success: ~Copyable, Failure: Error>(
    of: Success.Type,
    throws: Failure.Type = Never.self,
    file: StaticString = #file,
    line: Int = #line,
    _ body: (consuming Continuation<Success, Failure>) -> Void
) async throws(Failure) -> Success {
    await withUnsafeContinuation { (continuation: UnsafeContinuation<Success, Failure>) in
        body(Continuation(continuation, file: file, line: line))
    }
}

The of: parameter:

The of: label serves both a practical and an ergonomic purpose. With the existing with*Continuation API, the Success type is inferred from usage inside the closure, which often requires an explicit type annotation on the continuation parameter:

// Today: type annotation required inside the closure
let data = await withUnsafeContinuation { (continuation: UnsafeContinuation<Data, Never>) in
    bridge.store(continuation)
}

This pattern is verbose and buries the return type inside the closure signature. Compare with the proposed API:

// Proposed: type is clear at the call site
let data = await withContinuation(of: Data.self) { continuation in
    bridge.store(continuation)
}

This follows the same pattern used by other standard library APIs like withThrowingTaskGroup.
The type flows naturally at the call site, and the closure parameter needs no annotation.

The throws: parameter and typed throws:

SE-0300 introduced two separate free functions — withCheckedContinuation and withCheckedThrowingContinuation — because Swift had no way to express a single function that was conditionally throwing based on a type parameter. The only option was duplication.

SE-0413 (typed throws) removes that limitation. A single function parameterized over Failure: Error can now declare throws(Failure), and the compiler handles all three cases uniformly:

  • Failure == Neverthrows(Never) is non-throwing; the await expression requires no try.
  • Failure == MySpecificError — the call site must try and the compiler knows the thrown type exactly.
  • Failure == any Error — equivalent to the old untyped throws.

The throws: parameter is the type witness for Failure, serving the same role as of: does for Success: it surfaces the type at the call site rather than burying it in a closure signature.

// Non-throwing — throws: defaults to Never.self, no try needed
let data = await withContinuation(of: Data.self) { continuation in
        // ...
}

// Typed throwing — compiler knows only NetworkError can be thrown
let data = try await withContinuation(of: Data.self, throws: NetworkError.self) { continuation in
    // ...
}

// Untyped throwing — matches the old withCheckedThrowingContinuation behavior
let data = try await withContinuation(of: Data.self, throws: (any Error).self) { continuation in
    // ...
}

Two separate functions (withContinuation / withThrowingContinuation) would cover only the Never and any Error cases, forcing an API design that is already obsolete. A typed-throws API like withNetworkContinuation would be impossible to express without the unified form. The single parameterized function is both more expressive and forwards-compatible.

Capturing file and line:

File and line are captured to enable a better developer experience. This allows us to inform developers where a Continuation was created that leaked.

Behavior guarantees

Comparing the new Continuation type with the existing types:

Scenario UnsafeContinuation CheckedContinuation Continuation
Exactly one resume :white_check_mark: Works :white_check_mark: Works :white_check_mark: Works
Double resume :warning: Undefined behavior :collision: Runtime trap :cross_mark: Compile-time error
Missing resume :face_without_mouth: Silent hang :warning: Runtime warning :collision: Runtime trap
Runtime overhead None Allocation + atomic ops None

Source Compatibility

This proposal is purely additive. No existing code is affected.

The names withContinuation and Continuation do not conflict with any existing standard library API.

ABI Compatibility

This proposal is purely additive and does not change any existing ABI.

Implications on Adoption

Migrating from CheckedContinuation to Continuation is mechanical:

Before After
withCheckedContinuation { … } withContinuation(of: T.self) { … }
withCheckedThrowingContinuation { … } withContinuation(of: T.self, throws: (any Error).self) { … }
CheckedContinuation<T, E> Continuation<T, E>

Because Continuation is ~Copyable, some code patterns that implicitly copy the continuation (e.g., capturing it in a closure) will produce compile-time errors after migration. In those cases developers have to use the existing Checked/UnsafeContinuation syntax, depending on their use-case. Once Swift has gained call once closures, theses use of Checked/UnsafeContinuation can be migrated to the new Continuation syntax as well.

Future Directions

Capturing Noncopyable types in closures

Currently, Swift does not support capturing non Copyable types in closures. The reason for this is, that Swift currently does not have an annotation that allows closures to just be called once. In those cases users should continue to use the existing Continuation types.

Introduction of ~Discardable protocol

While the new Continuation improves the ergonomics of continuations in a lot of places, we still rely on a runtime trap, if a continuation is dropped. Since we use a noncopyable type here, the compiler already injects deinit calls at the places where a continuation is dropped. We could consider adding a new mode for ~Copyable types, that signals, that instead of adding deinits the compiler enforces that a type must be explicitly consumed. This would turn the runtime trap into a compiler error.

45 Likes

On my first quick read, that sounds very exciting. However, I have concerns about introducing a fourth continuation type. In my opinion, we should first add call-once closures before proceeding here, and then also deprecate the two older versions.

5 Likes

Implementation concern: Is it possible to build a typed-throws Continuation on top of the existing untyped-throws UnsafeContinuation, without something like

do {
  try await withUnsafeThrowingContinuation { ... }
} catch {
  throw error as! Failure
}

It currently is not, which is why the linked implementation currently does not support this. However we believe this to be possible once we move the impl into the stdlib. But first we want to gather early feedback.

2 Likes

Big +1! While I almost always use CheckedContinuation, regardless of the cost, this will improve and simplify continuation usage. Call-once closures will be an important final piece, but I would like this to move forward if there are no anticipated issues when those arrive.

1 Like

Yes, it is, with the Result type:

func f() async throws(SomeError) {
	try await withUnsafeContinuation { (continuation: UnsafeContinuation<Result<Void, SomeError>, Never>) in
		continuation.resume(returning: .failure(SomeError()))
	}.get()
}
5 Likes

Oh! Good point, will use that!

This functionality seems sorely missed! But... there is one part that I would claim is both not needed and adds extra burden; the file and line parameters are extra allocation that makes this considerably larger stride wise (when storing many continuations) and also emits additional strings into the binary for the source locations. Since the type is frozen it ends up preventing us from using this as a memory compatible drop in replacement to the unsafe version.

Since developers can just break on the fatalError and relatively trivially determine where the continuation came from, could we not just omit that data?

12 Likes

I don't disagree here.

I think by far the best experience would be, if we get compiler functionality, that prevents developers from dropping the continuation without resuming it (See section ~Discardable) – thus enforcing exactly once resumption at compile time.

I thought the file and line is a good stop gap, but I'm happy to remove it.

1 Like

agree, this should be as light as possible.

This syntax for the untyped error case seems like an unfortunate ‘pessimisation’ - writing out a typed error is 3 tokens less effort! Is there anything that could be done to improve it? Would a AnyError type alias work?

1 Like

The potential introduction of a ~Discardable protocol is significant enough that I believe we should postpone this proposal until we have that feature. If ~Discardable already existed, it would substantially change the design of this new Continuation.

First, the proposed implementation doesn't rely on any internal standard library or compiler features. Because it’s essentially just a wrapper around UnsafeContinuation anyone can easily build this exact type in their own codebase today. Given that continuations also aren't typically used as currency types, I'm not convinced this specific wrapper needs to be in the standard library right now.

Second, if the ultimate goal is to make continuations truly compile-time safe, introducing a ~Discardable protocol really should be a prerequisite. I’ve found myself wanting this exact kind of non-discardable behavior in the past. I think it would be much more beneficial to tackle the underlying language feature first, which would then allow us to build a perfectly safe Continuation type naturally.
Even if ~Discardable is ultimately rejected, it would still be beneficial to have that discussion before landing this proposal.

10 Likes

Somewhat orthogonal to this proposal, but in general I've found it to be a common mistake to call withCheckedContinuation without also calling withTaskCancellationHandler. I agree that sometimes you need one but not the other, but I feel like the common API should be for the common case of needing both.

I agree that if we're seriously considering ~Discardable, that proposal should order before this one. Generally Rust has avoided that because you can always leak the object to avoid destroying it anyway, so it can't participate in safety as such, but I do think it would still help in common usage.

Pretty much everywhere I use CheckedContinuation, they go into an Array inside a Mutex. You can't currently (without hacks) put noncopyables into a Mutex because the compiler can't prove that withLock doesn't call its closure more than once. And there's no stdlib type that can store a collection of noncopyables. Both feel like important limitations to lift before this API will be useful in practice.

6 Likes

It isn't as convenient but you can put a non-copyable value into an Optional and then use .take() to get the value out of it within the closure you pass to .withLock. Not as elegant though as if Mutex.withLock would properly understand this limitation.

While not in the stdlib, it isn't far away in the swift-collections package which has UniqueArray, RigidArray, UniqueDeque and RigidDeque. Hashed collections with support for non-copyable types have also just been merged into main.

2 Likes

I thought about this more, and I think it would make this type incredibly hard to use in reality and the fatalError deinit is a good tradeoff worth landing the feature with.

The reality of how continuations are used is integrating with "other code"™ most of the time, and it can be very difficult to statically prove a path is executed exactly once, especially in code you don't control and where continuations are used most of the time, e.g. turning a callback model into async model:

actor MyStateMachine {   
  // ...
  func onComplete() { // we "KNOW" this will be called exactly-once
    continuation.resume() // however there's no way to prove it
  }
}

Situations like this are just not realistically expressible using static guarantees, so we'd have to create some ways to turn it into "dynamically checked" anyway, even if we had the ~Discardable and full static guarantees.


Middle ground: warn where we can

We would be open to add a unintended-discard diagnostic to this type, the compiler can warn about discard continuation and we'de be comfortable with adding a bespoke warning about this. We'll work on adding it to the pitch.

4 Likes

There's good points about the weight of the type being made... perhaps we ought to drop the source location in order to make sure there's really minimum overhead from using this new continuation type...

1 Like

This is a massive tangent, but imo async let could use this.

+1 from me

If ~Copyable had existed at the time the SE-0300 has been proposed, the above is how I would have expected them to be implemented.

As others do I have some concerns about the additional weight the file and line number add. If the compiler can provide some support that’d be great.

3 Likes

I regrettably don’t have time to read all the discussion, but I think the language should either

  • allow configuration of the “uncalled” behavior, or
  • Not introduce this until we can make the ergonomics better, as in the future directions
1 Like

I don't think it would be particularly more difficult to prove that a path is executed exactly once than to prove that a path is executed at most once (because of the non-copyability). That is, for situations like MyStateMachine, we already have CheckedContinuation to check the exactly-once requirement dynamically instead of statically. If we're going to add some amount of static safety, I think it makes sense to go all the way. At least, I don't think a more-but-not-completely statically checked type should be given the "nice" name if it's on the table to have a completely statically checked type in the future.

4 Likes