RFC 039: race for Awaitable Concurrency¶
- Status: Draft
- Created: 2026-03-07
- Author(s): Danny Meijer (@dannymeijer)
- Related:
- RFC 023 (Compilable stdlib & Rust module binding)
- RFC 027 (
incan-vocab) - RFC 028 (Trait-based operator overloading)
- RFC 029 (Union types and type narrowing)
- RFC 035 (First-class named function references)
- RFC 038 (Variadic positional args and keyword-argument capture)
- Target version: TBD
Summary¶
Introduce race as an import-activated std.async vocabulary form for "first-completion wins" concurrency, together with an Incan-native Awaitable[T] protocol (trait) that formalizes what await means in generic code.
The architecture is deliberately layered:
awaitremains a core language feature in semantic terms. It is not replaced by ordinary library calls.Awaitable[T]is the Incan-facing protocol behindawait, in the same language-first spirit that RFC 028 applies to operators.raceis not an always-on core keyword. It is activated throughimport std.asyncand introduced through RFC 027's vocabulary/desugaring machinery.race for value:is surface sugar overstd.asynchelper APIs.- The long-term helper shape is variadic, via RFC 038:
std.async.race(*arms: RaceArm[R]) -> R.
RFC 029 matters here too: when different branches produce different value types, race can naturally return a union such as str | int instead of forcing every caller through Either-style wrappers.
The keyword is race, not select.
That choice is deliberate:
racematches the semantics: multiple awaitables compete, one completes first, the rest are cancelledraceavoids conflict with future query language surfaces that will useSELECTraceis a better name for arbitrary awaitables than Go-styleselect, which is channel-oriented
Motivation¶
The stdlib still lacks an Incan-first way to express generic awaitables¶
The current std.async surface already shows the missing language piece. A timeout helper should be straightforward Incan:
pub async def timeout_option[T, F with Awaitable[T]](seconds: float, task: F) -> Option[T]:
...
But today that contract cannot be expressed and preserved cleanly enough through the frontend and lowering pipeline. A generic parameter like TaskFuture can be named, but not properly constrained as "awaitable yielding T" in a way that makes await task typecheck as ordinary Incan code.
That leaves stdlib code in an awkward place:
- wrappers that should be ordinary Incan remain placeholders
- or they drop to narrow Rust-backed leaves earlier than they should
This RFC closes that gap by giving the language an explicit awaitable protocol and by giving std.async a concise syntax for racing awaitables.
await is the primitive; race is composition¶
Earlier iterations of this idea treated race as a new core expression. That is the wrong center of gravity.
The real semantic primitive is await.
await must remain a core language feature because:
- it participates directly in typechecking
- it defines a calling convention for async code
- it requires the compiler and backend to agree on suspension, resumption, and cancellation semantics
By contrast, race is one layer higher. It is a way of composing several awaits. Under RFC 027, that makes it a strong fit for import-activated vocabulary plus desugaring rather than a permanently reserved always-on keyword.
RFC 027, RFC 028, RFC 029, RFC 035, and RFC 038 all point to the same design¶
This RFC sits at the intersection of several other design decisions:
- RFC 027 gives Incan a vocabulary/desugaring path, so
racedoes not need bespoke parser wiring as a one-off compiler special case. - RFC 028 reinforces the language-first rule. Async semantics should be specified in Incan terms just as operators are specified in Incan terms.
- RFC 029 gives Incan anonymous sum types. That means the common result type of a race can naturally be a union when branch bodies yield different types.
- RFC 035 makes named functions first-class values. Combined with closures, that makes helper-style desugaring natural.
- RFC 038 gives the helper surface its right long-term shape: a single variadic
race(*arms)API instead of a growingrace2/race3/race4ladder.
Taken together, these RFCs point toward a cleaner architecture:
- define
awaitthrough an Incan protocol - define
raceasstd.asyncsugar - package branches as homogeneous
RaceArm[R]values - desugar
raceto a variadic helper over those arm values
Why not just keep library helpers?¶
Python and TypeScript prove that helper-style APIs are viable:
- Python has
asyncio.wait(...)andasyncio.as_completed(...) - TypeScript has
Promise.race(...)
Those are useful, but they are not the whole answer for Incan.
On their own they miss two things:
- Ergonomic surface syntax:
race for value:is easier to scan and teach than nested helper calls with inline lambdas. - A first-class awaitable model: without
Awaitable[T], helpers still cannot express "this generic value may be awaited and yieldsT".
So the right answer is not "syntax only" or "helpers only". It is both:
- core
awaitsemantics viaAwaitable[T] - helper functions in
std.async - syntax sugar over those helpers
race is the right word¶
This feature is the async sibling of match, but it is not literally match.
matchchooses a branch based on the shape of one value that already existsracechooses a branch based on which awaited operation completes first
That difference matters enough that reusing match would blur semantics rather than clarify them.
Goals¶
- Formalize the Incan-facing protocol behind
awaitasAwaitable[T]. - Allow generic APIs to say "this parameter is awaitable and yields
T". - Introduce
race for value:asstd.asyncvocabulary syntax activated by importingstd.async. - Desugar
raceinto ordinarystd.asynchelper calls rather than treating it as a one-off backend special form. - Use RFC 038's variadic capture to shape the long-term helper surface as
race(*arms: RaceArm[R]). - Make union return types from RFC 029 a first-class part of the
racestory. - Specify cancellation and tie-breaking semantics clearly.
Non-Goals¶
- Making
racean always-on core keyword. - Replacing
awaitwith a library function.awaitremains a core language feature. - Exposing Rust's
Future<Output = T>syntax directly in user-facing Incan. - Designing a full async trait system, async closures RFC, or effect system.
- Adding Go-style channel
selectas a separate feature in this RFC. - Adding
defaultarms, guarded arms, or fairness controls in v1.
Guide-level explanation¶
User model¶
Think of race as the async cousin of match.
matchsays: "inspect one value and choose a branch"racesays: "wait on several awaitables and choose the branch attached to the winner"
The winning branch gets a value binding. The losing branches are cancelled.
Basic syntax¶
result = race for value:
await fast() => value
await slow() => value
This reads as:
- start both awaitables
- whichever completes first binds its result to
value - evaluate the corresponding branch body
- cancel the losing awaitable
Union results fit naturally¶
RFC 029 removes a lot of wrapper pressure here.
result: str | int = race for value:
await fetch_text() => value
await fetch_count() => value
match result:
case str(s):
println(f"text: {s}")
case int(n):
println(f"count: {n}")
The two arms await different result types, but the branch bodies still agree on one final type: str | int.
Use an enum when provenance matters¶
If both branches produce the same type and you still need to know which branch won, use an explicit wrapper in the branch bodies:
pub enum Source:
Primary(str)
Replica(str)
result = race for value:
await fetch_primary() => Source.Primary(value)
await fetch_replica() => Source.Replica(value)
This keeps race simple. The syntax decides the winner; ordinary Incan types decide how much provenance you want to carry afterwards.
Timeout becomes ordinary std.async composition¶
Once Awaitable[T] exists, timeout helpers become straightforward:
pub async def timeout_option[T, F with Awaitable[T]](seconds: float, task: F) -> Option[T]:
return race for value:
await task => Some(value)
await sleep(seconds) => None
The public surface is plain Incan. The helper is described in Incan terms, even if its eventual backend realization uses Tokio or another runtime.
What the helper desugaring looks like¶
The intended long-term desugaring target is variadic:
result = race for value:
await fast() => value
await slow() => value
conceptually becomes:
result = await std.async.race(
std.async.arm(fast(), (value) => value),
std.async.arm(slow(), (value) => value),
)
This is where RFC 038 matters. Without variadics, the helper surface tends to fragment into fixed-arity forms. With variadics, the public API can stay clean.
Named handlers also work¶
RFC 035 matters here because helper-style desugaring becomes natural:
def on_fast(value: str) -> str:
return value
def on_slow(value: str) -> str:
return value
result = await std.async.race(
std.async.arm(fast(), on_fast),
std.async.arm(slow(), on_slow),
)
Users do not have to write the helper call directly, but when they do, named function references and closures both work.
Matching is still done with ordinary match¶
This RFC intentionally does not require pattern bindings inside race arms.
If you want to inspect the winner's shape, you do that in ordinary Incan:
result = race for msg:
await rx_a.recv() =>
match msg:
Some(value) => f"a: {value}"
None => "a closed"
await rx_b.recv() =>
match msg:
Some(value) => f"b: {value}"
None => "b closed"
That keeps race focused on concurrency while letting match keep its existing role as the value-shape construct.
Reference-level explanation¶
Activation and status¶
race is not always available.
It becomes active when std.async is imported, following RFC 027's unified vocabulary model. A file that never imports std.async does not gain race.
Semantic layering¶
This RFC distinguishes three layers:
- Core semantic layer:
awaitandAwaitable[T] - Library layer: helper values and helper functions in
std.async - Vocabulary layer:
race for value:syntax, which desugars to the helper layer
The design intentionally avoids collapsing all three into one special-case compiler feature.
Awaitable[T]¶
This RFC introduces an Incan-facing protocol:
trait Awaitable[T]:
# builtin protocol used by `await`
This is a language hook. Like the operator protocols of RFC 028, it is specified in Incan terms first and mapped to backend constructs second.
The user-facing rule is:
await expris valid only ifexprhas some typeFsuch thatF with Awaitable[T]for someT- the result type of
await exprisT
Backends may lower this however they need to. On Rust, that will likely mean a representation equivalent to Future<Output = T>, but that is backend guidance, not the language model.
Bound syntax¶
This RFC gives practical meaning to:
F with Awaitable[T]
This means:
- values of type
Fmay be awaited - awaiting them yields a value of type
T
This is the missing piece that lets generic async wrappers be expressed cleanly in Incan source.
Surface syntax¶
The primary surface syntax is:
race_for_expr ::= "race" "for" IDENT ":" NEWLINE INDENT race_for_arm+ DEDENT
race_for_arm ::= "await" expr "=>" race_body
race_body ::= expr | NEWLINE INDENT stmt+ DEDENT
Example:
result = race for value:
await fast() => value
await slow() => value
The binding name after for is in scope inside each arm body, but each arm gets its own logically separate binding.
Context restrictions¶
raceis only valid insideasync def.- Every arm in v1 is an
awaitarm. - All arm bodies must produce a single common result type.
- That common result type may be a union, subject to RFC 029's rules.
raceis expression-position syntax.
Helper API shape¶
The long-term helper family is expected to look roughly like this:
pub type RaceArm[R] = ...
pub def arm[T, R, F with Awaitable[T]](
awaitable: F,
on_win: (T) -> R,
) -> RaceArm[R]
pub async def race[R](*arms: RaceArm[R]) -> R
The important design choice is that the variadic parameter is homogeneous. Each branch is packaged into a RaceArm[R] first, and only then passed through *arms. This is what lets RFC 038 solve the arity problem cleanly.
Desugaring model¶
Conceptually:
result = race for value:
await fast() => transform_fast(value)
await slow() => transform_slow(value)
desugars to:
result = std.async.race(
std.async.arm(fast(), (value) => transform_fast(value)),
std.async.arm(slow(), (value) => transform_slow(value)),
)
The exact internal representation is an implementation detail, but the architectural point is important: race is best understood as syntax sugar over helper APIs, not as a hidden one-off backend primitive.
Transitional implementation note¶
If RFC 038 is not available at initial implementation time, fixed-arity helpers such as race2 and race3 are acceptable as a stepping stone.
They are not the desired long-term public architecture.
Type checking rules¶
For a race for value: expression:
- Each awaited expression must typecheck as some
Awaitable[T_arm]. - Inside that arm body,
valuehas typeT_arm. - The binder is arm-local; reusing the same name across arms is legal and does not imply the same type.
- Every arm body must typecheck to the same result type
R. Rmay be an ordinary type, an enum, or a union from RFC 029.- The overall
raceexpression has typeR.
Example:
return race for value:
await fetch_user() => Ok(value)
await fetch_error_code() => Err(value)
This typechecks if both branches produce the same outer type, for example Result[User, int].
Runtime semantics¶
When evaluation enters a race expression:
- All awaited arm expressions are started in the current async context.
- The runtime polls them concurrently.
- The first arm to complete wins.
- The winning arm body is evaluated.
- Losing awaitables are cancelled by being dropped.
This is not the same as spawning detached tasks. race multiplexes several awaitables within one async flow.
Cancellation semantics¶
Cancellation is cooperative:
- losing arms do not continue running to completion
- dropping a losing awaitable triggers whatever cleanup that awaitable normally performs
- code must not assume side effects after the final suspension point of a losing arm will still happen
This is the same semantic territory as runtimes like Tokio, but the language definition stays backend-agnostic.
Tie-breaking¶
If more than one arm becomes ready at the same poll point, v1 chooses the first arm in source order.
This gives deterministic behavior and keeps the first version easy to reason about.
Backend guidance¶
The Rust backend will likely realize Awaitable[T] in terms equivalent to Rust futures and realize std.async.race(...) in terms equivalent to tokio::select! or a narrow helper facade.
That is explicitly backend guidance, not the normative language definition.
Examples¶
Fastest mirror wins¶
async def fetch_file() -> bytes:
return race for data:
await http_get(PRIMARY_URL) => data
await http_get(MIRROR_URL) => data
Heterogeneous winner¶
result: str | int = race for value:
await fetch_text() => value
await fetch_count() => value
Direct helper use¶
pub async def fastest_text() -> str:
return await std.async.race(
std.async.arm(fetch_primary(), (value) => value),
std.async.arm(fetch_replica(), (value) => value),
std.async.arm(fetch_cache(), (value) => value),
)
Why not select¶
select was considered and rejected.
Reasons:
SELECTis reserved for future query language surfaces, so reusing the word would create unnecessary ambiguity- Go-style
selectis channel-oriented, while this RFC is about arbitrary awaitables racedescribes the behavior directly and keeps expectations cleaner
This does not rule out a future channel-specialized construct if that later proves worthwhile.
Why not async match¶
async match was also considered.
It sounds attractive at first because race is the async cousin of match, but the semantics are different enough that overloading match would blur the model:
matchinspects one value that already existsracewaits on several awaitables and cancels losers
Incan already has a good story for "await one thing, then match it":
match await rx.recv():
Some(msg) => handle(msg)
None => handle_closed()
race is needed specifically for the multi-await case.
Alternatives considered¶
1. Make race a hard core keyword¶
Rejected as the preferred framing.
Pros:
- simpler to describe in isolation
- direct compiler ownership of the syntax
Cons:
- misses RFC 027's vocabulary/desugaring architecture
- overstates how special
racereally is compared to the true primitive,await - makes the feature feel more compiler-owned and less stdlib-shaped than necessary
2. Fixed-arity helper APIs only¶
Rejected as the long-term design.
Pros:
- easy stepping stone for implementation
- no dependency on RFC 038
Cons:
- proliferates
race2,race3,race4, and so on - teaches the wrong shape for the public API
- makes syntax sugar less cleanly explainable
3. Pure helper APIs with no syntax sugar¶
Rejected as the user-facing design.
Pros:
- minimal syntax work
- familiar to Python and TypeScript users
Cons:
- clunkier for common first-wins code
- loses the clarity of an arm-oriented surface
- still requires
Awaitable[T]work anyway
The helpers should exist, but syntax sugar over them is worthwhile.
4. Expose Rust-like Future<Output = T>¶
Rejected for Incan source.
Pros:
- maps closely to the Rust backend
Cons:
- leaks Rust concepts into the public language model
- introduces associated-type syntax before users need to think in those terms
- conflicts with RFC 028's language-first philosophy
5. Treat race as a hidden intrinsic instead of helper sugar¶
Not preferred.
Pros:
- can simplify an initial backend implementation
Cons:
- obscures the stdlib-facing model
- underuses RFC 035's function-reference story
- no longer benefits as directly from RFC 038's variadic design
An implementation may still use internal helpers or specialized lowering, but the public architecture should be helper-shaped.
Drawbacks¶
Awaitable[T]adds a new builtin protocol that the compiler must understand.raceadds async-specific vocabulary users must learn.- cancellation semantics require careful documentation and testing.
- RFC 027 may need a small extension if expression-position vocab blocks are not yet covered cleanly enough.
- RFC 038 becomes a meaningful architectural dependency for the ideal helper surface, even if fixed-arity helpers can bridge the gap temporarily.
These costs are acceptable because they buy a much cleaner async story for the stdlib and future libraries.
Unresolved questions¶
- Should
Awaitable[T]be user-implementable in v1, or compiler-recognized only? - Should
std.async.selectbe renamed tostd.async.race, with compatibility exports left behind? - Does RFC 027 need a dedicated expression-block surface kind for
race for value:? - Should a later version add a more general pattern-binding
raceform, or israce for value:plus ordinarymatchsufficient? - Should a later version add
defaultarms, guard expressions, or unbiased scheduling options?
Layers affected¶
- Core language / Typechecker —
Awaitable[T]as a builtin protocol;await exprmust verify the awaited expression satisfiesAwaitable[T]and the result type follows; bound lowering must preserveF with Awaitable[T]through the frontend and IR - Parser / Vocabulary —
race for value:as import-activated syntax (requiresimport std.async); follows RFC 027's vocabulary/desugaring model; expression-position block form may require a small extension to RFC 027's surface kinds - IR Lowering — desugar
race for value:intostd.async.race(std.async.arm(...), ...)calls; transitional fixed-arity helpers (race2,race3) are acceptable until RFC 038 variadics land - Stdlib (
std.async) —RaceArm[R],arm(awaitable, on_win), andrace(*arms: RaceArm[R])helper types and functions; the existingstd.async.selectplaceholder helpers should be rewritten as real implementations once the model is in place - Rust backend —
Awaitable[T]maps to Rust future semantics;std.async.race(...)maps totokio::select!or a narrow helper facade; this is backend guidance, not the normative language definition