Why does async let turn rethrows into throws in Swift?

If you’ve used structured concurrency in Swift enough, you may have noticed that the rethrows keyword doesn’t always “work” with async/await, at least according to our intuitions about when try should and shouldn’t be required.

This article will attempt to shed some light on how rethrows actually works in Swift, and explain why some async functions still need to be marked throws, even if they only call throws functions that were passed to them as arguments.

Suppose we have a function Example.delay(by:_:), which sleeps for the specified amount of time, and then executes the closure argument, returning its result. It has the following signature and definition:

func delay<T>(by nanoseconds:UInt64, _ body:() throws -> T) 
    async rethrows -> T
{
    try? await Task.sleep(nanoseconds: nanoseconds)
    return try body()
}

The function Example.delay(by:_:) is a normal Swift rethrows function. It is async, but we are still allowed to omit try if we pass it a non-throwing closure argument.

func delay(value:Int, by nanoseconds:UInt64) 
    async -> Int
{
    await delay(by: nanoseconds){ value }
}

Omitting try also works if we use async let to call Example.delay(by:_:) on a concurrent Task.

func delay(values:(Int, Int), by nanoseconds:UInt64) 
    async -> (Int, Int)
{
    async let first:Int  = delay(by: nanoseconds){ values.0 }
    async let second:Int = delay(by: nanoseconds){ values.1 }
    return await (first, second)
}

What if we want to get an Int value from a closure? Since the inner Example.delay(by:_:) implementation also takes a closure parameter, we can avoid evaluating body in the caller, and simply forward it to the callee.

func delayInt(by nanoseconds:UInt64, _ body:() throws -> Int) 
    async rethrows -> Int
{
    try await delay(by: nanoseconds, body)
}

Observe that Example.delayInt(by:_:) can be marked rethrows, since the inner Example.delay(by:_:) function is also rethrows. This fits with our intuition of rethrows.

What happens if we call Example.delay(by:_:) on a concurrent Task instead? We will need to constrain the body closure to be @Sendable. Even so, the Example.delayInt2(by:_:) example below will fail to compile, because the try used to await the delayed binding is not a rethrows-compatible try.

func delayInt2(by nanoseconds:UInt64, _ body:@Sendable () throws -> Int) 
    async rethrows -> Int
{
    async let delayed:Int = delay(by: nanoseconds, body)
    return try await delayed
    //               ^~~~~~~
    // error: call can throw, but the error is not handled; 
    // a function declared 'rethrows' may only throw if its 
    // parameter does
}

This is not the fault of @Sendable. Sendability is a constraint, which means any valid body argument would still be valid if @Sendable were removed.

The reason we get a compiler error for Example.delayInt2(by:_:) is because rethrows is not actually a type-level feature. In Swift, a function is either throws or completely non-throwing. In fact, rethrows is a call-site level feature.

If we inspect the type signature of a rethrows function such as Example.delayInt(by:_:), we can observe that the Swift compiler considers it a throws function.

print(type(of: delayInt(by:_:)))
// \(type(of: Example.delayInt(by:_:)))

Why does this even matter? If we consult the definition of async let from SE-0317, we’ll notice that async let is defined in terms of Task. This means that when we create an async let binding, we are implicitly creating a new Task, and converting the static function call to a function object. The original rethrows context has no knowledge of how this function object will be called within the newly created concurrent execution context.

func delayIntWithTask(by nanoseconds:UInt64, 
    _ body:@Sendable () throws -> Int) 
    async rethrows -> Int
{
    let task:Task<Int, Error> = .init
    {
        try await delay(by: nanoseconds, body)
        //                               ^~~~
        // `body` was enclosed by the `Task`, which means 
        // `delayIntWithTask` has no idea this is a `rethrows` call.
    }
    return try await task.value
    //                    ^~~~~
    // error: property access can throw, but the error is not handled; 
    // a function declared 'rethrows' may only throw if its 
    // parameter does
}

The upshot is that, for now, these kinds of functions still need to be marked throws, even if they are effectively rethrows.

Task is unlikely to ever truly support rethrows, as that would require a full-scale re-architecting of the Swift type system. However, one of the more promising aspects of structured concurrency is that, in theory, we could statically reason about rethrows-like behavior for async let bindings, in a way that we cannot for unstructured Task objects. It remains to be seen if this will be added in a future version of Swift.