try
is used to mark places that can throw errors. The declaration of veggies
cannot throw an error; only the uses of veggies
can throw errors, which is why we have:
async let veggies = chopVegetables()
//...
try await veggies
It's like the difference between forming a closure that can throw, and calling that closure, as @jazzbox points out:
let fn = { throw SomeError() } // no `try` here
try fn() // the try goes here, where you can observe the error

This would give us a simple design that composes better with existing-Swift, and reduces the amount of reinvention:
@ThrowingFuture let veggies = { try await chopVegetables() } @Future let meat = { await marinateMeat() } @Future let oven = { await preheatOven(temperature: 350) } let dish = Dish(ingredients: await [try veggies, meat]) return try await oven.cook(dish, duration: .hours(3))
This approach would also be a HUGE improvement over the code in the motivation section
I've been trying to make property wrappers work as an async let
replacement for the better part of a year, and I've come to the conclusion that property wrappers aren't the right tool for this task: they cannot provide the semantics we need for child tasks without a pile of magic attributes to customize their behavior, at which point we've lost all but the most superficial benefits of using property wrappers. I'll explain point-by-point since you have a long list:

It is dramatically less invasive on the core compiler, which is a good sign it will compose better with other features.
I doubt this would pan out. Aside from the special rules about initialization and the need to try
or await
on access, async let
is just a let
throughout the core parts of the compiler. Most of the complexity of the implementation is in later compiler stages, to ensure that we create/cancel/finish child tasks at the right places and that we take advantage of the scoped model to eliminate excess heap allocations. That complexity doesn't go away if we use property wrapper syntax here, but because you need to invent various magic attributes to get the right semantics, you actually have more work to do (and more chances for failure) because you have to work backwards from pseudo-generalized code to the specific efficient implementation.

It builds on the existing functionality we have to wrap a property (property wrappers) instead of inventing new type system rules through a decl modifier.
Property wrappers need to be heavily customized to get the semantics we need for child tasks. Some issues I found while exploring this:
-
Property wrappers always create a backing storage property (
_veggies
). We would need to invent an attribute to make that backing storage property inaccessible to users, because we don't want to imply that there is a way to refer to the child task. The fact that one cannot access the futures behind child tasks is one of the core aspects of structured concurrency. -
You need some kind of
deinit
on the backing storage property to handle implicit cancellation. We don't have those on structs or enums, so either we need to implement that (which I suspect we'll do some day as part of move-only types) or we need some kind of forced stack allocation of classes to avoid the cost of heap traffic here. -
The
deinit
isn't even enough for waiting for the task at the end of the scope, because it cannot beasync
and our reference-counting model wouldn't wait until the end of the scope to calldeinit
anyway. So, we'd need to invent some kind of attribute, protocol, or ad hoc protocol for "run some code at the end of the lexical scope" that ties in with these property wrappers. I'm not sure that's something we would even want as a general feature: we've pretty consistently stated that using "with" blocks to introduce a lexical scope is the only wait to get guaranteed lifetimes for operations. -
Property wrappers currently only work with
var
, because the actual variable being declared is a computed property. Either your examples need to usevar
or we need to consider allowing property wrappers to be defined as alet
.
The benefits we get from using property wrappers here are mostly superficial. I think we'll end up in the uncanny valley where what we have looks like a property wrapper, but is indeed so customized for this one purpose that the resemblance doesn't aid understanding: it would be better to present this as its own separate language feature.

It doesn't overload the
async
keyword to mean something different than the effect modifier. To point out one problem,throws
andasync
are both effects and should be generally treated equally: we don't want to supporttry let x = ..
as a deferred throwing feature.
The overloading is pulling together the structured asynchronous and concurrent constructs under "things that are await
'able".
As others have noted, we could do the async throws let
thing and it would both be consistent across effects and also address the concerns about not knowing that the initializer could throw.

By using closure syntax, we more clearly delineate that the initializer expressions are not part of the enclosing scope. This matters because they do/should introduce a
@Sendable
check, they should change the behavior ofactor self
to require an sync jump, and will have other effects. This is load bearing syntax, not boilerplate.
This point is actually completely independent of property wrappers. We could require
async let veggies = { try await chopVegetables() }
However, we should not require the braces. The type of veggies
is [Vegetable]
. It is not ThrowingFuture<[Vegetable]>
or () async throws -> [Vegetable]
. The initializer expression for a declaration should match the type of the pattern in the declaration, so the initializer expression should be the expression that produces [Vegetable]
.
Now, we could of course bend the rules for async let
and require the braces despite the above. It's actually harder to bend the rules with property wrappers, because property wrappers really want the type of the wrappedValue
property and the type of the parameter of init(wrappedValue:)
to match. That means you don't really get this syntax:
@ThrowingFuture var veggies = { try await chopVegetables() } // doesn't really work
you need to either do something like this:
@ThrowingFuture { try await chopVegetables() }
var veggies
or use autoclosures to get brace-free syntax:
@propertyWrapper
struct ThrowingFuture<T> {
var wrappedValue: T { get async throws { ... } }
init(wrappedValue: @escaping @Sendable @autoclosure () async throws -> T) { ... }
}
@ThrowingFuture var veggies = try await chopVegetables()

By making the closure explicit, it means that we can bring back the
try
andawait
modifiers in the initializer list and it makes sense what they mean and they are now consistent with the rest of the language.
We can decide to do this independently of whether we use property wrappers. I think async throws let
offers the most promise.

The use of closure syntax allows us to use multi-statement computation of the value, not just a single inline expression. This is more powerful.
We have an established pattern for this: { ... multi-line-closure ... }()

Using a property wrapper means that you get the existing property wrapper behavior with tuples, you probably don't get the fancy stuff described in the pattern section .
The writing here makes it sound more complicated than it is; mostly, it's describing the impact of transferring the effects from the initializer to each of the bound variables.
As I said much earlier, we went far down the path of trying to make property wrappers work, based on the same intuition you're describing here. It's part of why Swift 5.4 got property wrappers support for local variables, and effectful properties came along. But the semantic model of async let
isn't actually amenable to property wrappers, and structured child tasks described via local values are important enough to justify their own sugar.
Doug