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.
Grammar
Section titled “Grammar”AwaitExpression = "await" UnaryExpression .AsyncFunction = FunctionDeclaration ; // whose body contains AwaitExpressionExplicitAsyncWrapper = "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.
Async function result shape
Section titled “Async function result shape”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 declaration | Source-level result of await f() | emitted CLR result | builder / lowering shape |
|---|---|---|---|
func f() -> T { await … } | T | ValueTask<T> | AsyncValueTaskMethodBuilder<T> |
func f() { await … } | void | ValueTask | AsyncValueTaskMethodBuilder |
func f() -> Task<T> { await … } | T | Task<T> | AsyncTaskMethodBuilder<T> |
func f() -> Task { await … } | void | Task | AsyncTaskMethodBuilder |
func f() -> ValueTask<T> { await … } | T | ValueTask<T> | AsyncValueTaskMethodBuilder<T> |
func f() -> ValueTask { await … } | void | ValueTask | AsyncValueTaskMethodBuilder |
func f() -> void { await … } | void | void | AsyncVoidMethodBuilder |
func f() -> IAsyncEnumerable<T> { yield … } | stream element T | IAsyncEnumerable<T> | async-stream lowering |
task func f() -> T { await … } | T | Spawned<T> | state machine + spawn wrapper |
task func f() { await … } | void | Spawned | state 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 bodyfunc ping() -> Task { return Task.CompletedTask } // hands back a completed wrapperfunc answer() -> Task<int> { } // ill-formed: no result producedA 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 evaluation
Section titled “await evaluation”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>andValueTask<T>yieldT.TaskandValueTaskyieldvoid.- a user type such as
Spawned<T>yields the result type of its awaiter’sGetResultmember. - 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.
Calls to uncolored async functions
Section titled “Calls to uncolored async functions”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 anasync letinitializer): the call is the raw awaitable and the await owns the suspension;await f()yields the declared resultT. - As the direct initializer of a
letorvarbinding: 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(), aValueTask<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 toawaitit or bind it withlet/varfor a deferred join.
func read() -> int = await Task.FromResult(40)func addTwo() -> int = read() + 2 // joins read() inline — warns ES2240func addAwait() -> int = await read() + 2 // the awaited form; no warningBoth 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.
Naming
Section titled “Naming”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.
Force-on-use futures
Section titled “Force-on-use futures”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.