Skip to content

Awaiting and async return shapes

This section specifies the boundary between source-level values and the CLR awaitables emitted for a function that suspends. It supplements Concurrency; it does not introduce an async declaration modifier.

AwaitExpression = "await" UnaryExpression .
AsyncFunction = FunctionDeclaration ; // whose body contains AwaitExpression
ExplicitAsyncWrapper = "Task" [ "<" Type ">" ]
| "ValueTask" [ "<" Type ">" ]
| "IAsyncEnumerable" "<" Type ">" .

A function is awaitable when a caller can await its result. There are two ways to be one, and neither outranks the other: the body contains an AwaitExpression, or the declared return type is an ExplicitAsyncWrapper. An await in a nested function literal belongs to that literal, not to its enclosing function.

Only the first of those two makes the function suspend. A body with no await of its own runs to completion at its call and hands back an already-completed wrapper — a forwarder returns the awaitable it received (func asTask() -> Task = self.task), and a body that produces nothing completes when it ends. Its caller awaits the result the same way either way, which is what makes awaitability a property of the TYPE and why no async modifier exists to mark it.

The declared result controls the generated CLR surface. For an async function, a bare result type is the unwrapped source result, while an explicit task type is an interoperation commitment. The following table is normative.

Source declarationSource-level result of await f()emitted CLR resultbuilder / lowering shape
func f() -> T { await … }TValueTask<T>AsyncValueTaskMethodBuilder<T>
func f() { await … }voidValueTaskAsyncValueTaskMethodBuilder
func f() -> Task<T> { await … }TTask<T>AsyncTaskMethodBuilder<T>
func f() -> Task { await … }voidTaskAsyncTaskMethodBuilder
func f() -> ValueTask<T> { await … }TValueTask<T>AsyncValueTaskMethodBuilder<T>
func f() -> ValueTask { await … }voidValueTaskAsyncValueTaskMethodBuilder
func f() -> void { await … }voidvoidAsyncVoidMethodBuilder
func f() -> IAsyncEnumerable<T> { yield … }stream element TIAsyncEnumerable<T>async-stream lowering
task func f() -> T { await … }TSpawned<T>state machine + spawn wrapper
task func f() { await … }voidSpawnedstate machine + spawn wrapper

An omitted return type is the non-generic ValueTask case only when the function actually suspends. The explicit -> void form is distinct: it produces CLR async-void and should be reserved for event-handler contracts. A conforming implementation shall not wrap a declared Task<T> again; its body returns the unwrapped T, and its emitted method returns exactly Task<T>.

The table’s rows all suspend. A body with no await of its own keeps its declared type, and a Task is an ordinary value it may hand back. The one rule that follows from awaitability rather than from suspension is this: Task and ValueTask carry no result, so reaching the end of such a body is its completion. A conforming implementation shall not require a return there, and shall complete the wrapper itself. The generic forms are unaffected — a Task<T> owes a T, and a body that does not produce one is ill-formed.

func record(n: int) -> Task { } // completes at the end of the body
func ping() -> Task { return Task.CompletedTask } // hands back a completed wrapper
func answer() -> Task<int> { } // ill-formed: no result produced

A requirement is a signature, so whether a body awaits cannot decide whether it fills one. A -> Task interface slot is satisfied by any -> Task method, at either spelling.

A task func is the one form whose caller-facing type is not an awaitable wrapper. Its emitted signature is Spawned / Spawned<T> whether or not its body awaits: an awaiting body compiles to an ordinary async inner method, and the declared function is the wrapper that starts it and hands back the handle. Because that handle is already the caller-facing value, a task func call is never an uncolored call — it is not wrapped again, it is not joined in place, and it is never diagnosed as a forgotten await (ES2240).

func defaultShape() -> int { return await Task.FromResult(42) }
func taskShape() -> Task<int> { return await Task.FromResult(42) }
func consume() -> int {
let a = await defaultShape()
let b = await taskShape()
return a + b
}

Both awaits yield int. The difference exists at the CLR boundary, where taskShape is suitable for an API or interface slot requiring Task<int>.

await e evaluates e once. It obtains the awaiter through the normal .NET awaitable pattern, arranges a continuation if the operation is incomplete, and yields the awaiter’s result when the enclosing function resumes. A fault from e or its awaiter is rethrown at the await expression. Task, ValueTask, their generic forms, and any type with a compatible public GetAwaiter member are awaitable.

The result type is determined as follows:

  • Task<T> and ValueTask<T> yield T.
  • Task and ValueTask yield void.
  • a user type such as Spawned<T> yields the result type of its awaiter’s GetResult member.
  • an expression that does not provide the awaitable pattern is ill-formed.

An await expression is permitted anywhere an expression is permitted, subject to the surrounding construct’s normal type requirements. Its enclosing function is then asynchronous, even if the await occurs in a branch that is not taken at run time. An await inside a nested function literal or a spawn body belongs to that body, which becomes asynchronous on its own; it does not make the enclosing function asynchronous.

An await does not capture the synchronization context. A continuation resumes on the completing thread or on the thread pool, never by posting back to a context captured at the suspension point. This is uniform and not configurable: E# emits its own state machines, so the disposition is a property of the language rather than a call the programmer makes at each await. It is also what makes the call-site ladder below coherent — a let-bound future joins by blocking, and a blocking join above a context-capturing continuation would deadlock against itself.

An invocation of an E# async function whose declaration has a bare result type is an uncolored call. The compiler makes the underlying ValueTask visible only as needed to implement the call; source typing follows the declared result type. There is no implicit await. An un-awaited uncolored call is a joinable blocking future, regardless of whether the calling function is itself asynchronous. The call site’s disposition is fixed by its position:

  • Under an explicit await (including an async let initializer): the call is the raw awaitable and the await owns the suspension; await f() yields the declared result T.
  • As the direct initializer of a let or var binding: a force-on-use future — the invocation starts at the declaration and joins at the name’s first use (next section).
  • Into a slot explicitly typed by the wrapper (let t: ValueTask<int> = f(), a ValueTask<T>-typed parameter or assignment target): the raw awaitable value, caller-handled — neither joined nor future-lowered.
  • Any other position — an operand, an argument, a return expression, a bare statement — requires the value in place, so the call joins immediately (GetAwaiter().GetResult()), blocking the current thread. A conforming implementation shall issue warning ES2240 (sync-over-async) at such a call, directing the programmer to await it or bind it with let/var for a deferred join.
func read() -> int = await Task.FromResult(40)
func addTwo() -> int = read() + 2 // joins read() inline — warns ES2240
func addAwait() -> int = await read() + 2 // the awaited form; no warning

Both forms yield int and the same value; the awaited form suspends where the un-awaited form blocks. These rules are identical for a free function, an in-body or receiver method (self.pump()), a static-facet member, and a namespace-qualified call — the callee’s asyncness resolves through the same member resolution as the call itself.

Calls whose declaration explicitly exposes Task, ValueTask, or IAsyncEnumerable<T> are ordinary CLR values. They are never implicitly joined or future-lowered, because their explicit wrapper is part of the declared API.

As in .NET, suffixing a suspending function with Async (fetchAsync, receiveNextAsync) is encouraged. Because async is uncolored, a call site carries no syntactic marker of its own — the suffix is the one visual cue that a bare invocation is a future or an inline join rather than an ordinary call, and that an explicit-wrapper function hands back an awaitable. This is a convention, not a rule: no diagnostic enforces it, and omitting it changes nothing about binding or lowering.

A direct binding of an un-awaited uncolored call has a special rule, in any caller — synchronous or asynchronous:

FutureBinding = ( "let" | "var" ) identifier [ ":" Type ] "=" UncoloredAsyncInvocation .

A FutureBinding starts the uncolored invocation at the declaration, retains its generated ValueTask<T> in an implementation slot, and declares the source name with type T. The first executed read of that name joins the retained awaitable and yields the result. The join reads a preserved view of the ValueTask: a ValueTask may be consumed only once and its result is undefined before completion, while a binding is read at its first use and again at every later one, so the preserved form is what makes the repeat read and the pre-completion wait both well-defined — and unlike converting to a Task, it allocates nothing. A compiler shall preserve normal control flow: a read inside an untaken branch does not join, and separate bindings join in their first-use order.

func load(n: int) -> int = await Task.FromResult(n)
func use() -> int {
let left = load(19) // both calls begin at their declarations
let right = load(23)
return right + left // joins right, then left
}

This rule applies to a let or var initialized directly by a call to a bare-result E# async function — untyped, or typed by the result type. A binding explicitly typed by the awaitable wrapper (let t: ValueTask<int> = load(1)) keeps the raw awaitable instead. It does not apply to an arbitrary awaitable expression or a function that explicitly returns Task, ValueTask, or IAsyncEnumerable<T>; those forms retain their ordinary declared types and must be awaited, joined, or otherwise handled explicitly.

The join blocks the current thread only until completion and rethrows a fault at the first read — even inside an asynchronous function, where the join is a blocking bridge, never an await point. It is a synchronous bridge, not detached work and not a diagnostic for a forgotten await.