F# has provided two complementary async mechanisms via FSharp.Core since V6: Async<'T> and Task<'T>. There's a long history of third party helper libraries providing shimming and/or alternate behaviors as expedient solutions to practical needs. For example, we have the long-awaited AwaitTaskCorrect resolution, and the fact that key Async helpers like bind, ignore, catch etc are (tupled) methods spread across the Async and AsyncBuilder types with PascalCased names (i.e. async.Bind vs Async.Ignore). Similarly, while there is an Async.Ignore, there is no task.Ignore or Task.ignore.
This suggestion proposes modules for Task, Async and ValueTask with consistent naming and pit-of-success behaviors OOTB via FSharp.Core.
Note this proposal is intentionally not seeking to be complete at the expense of drowning all F# users in a sea of new functions offering marginal gains; the aim is to cover 90% of common needs, with potential future extensions driven by clear evidence of needs across a diverse set of codebases.
All proposed additions are camelCase module-level functions. Where a CE builder method already has the right semantics, the module function simply delegates to it β introducing no new behaviour:
module Async =
let inline singleton (value: 'T) = async.Return value
let inline map (f: 'T -> 'U) (a: Async<'T>) = async.Bind(a, f >> async.Return)
let inline bind (f: 'T -> Async<'U>) (a: Async<'T>) = async.Bind(a, f)
val inline ignore (a: Async<'T>) = Async.Ignore a
module Task =
let inline singleton (value: 'T) = task.Return value
let inline map (f: 'T -> 'U) (t: Task<'T>) = task.Bind(t, f >> task.Return)
let inline bind (f: 'T -> Task<'U>) (t: Task<'T>) = task.Bind(t, f)
val inline ignore (t: Task<'T>) = (* See Appendix B *)The sole exception is Async.ofTask / Async.ofTaskUnit, which delegate to a new pair of Async.Await overloads that fix the long-standing incorrect AggregateException unwrapping in Async.AwaitTask (see Appendix A).
| Icon | Meaning |
|---|---|
| β | Exists in FSharp.Core today (may have known issues β see notes) |
| π² | Proposed |
| β | Not proposed / out of scope |
| Function | Async β current |
Async β proposed |
Task β proposed |
ValueTask β proposed |
|---|---|---|---|---|
singleton |
β
async.Return |
π² | π² | π² |
map |
N/A | π² | π² | π² |
bind |
β
async.Bind |
π² | π² | π² |
ignore |
β
Async.IgnoreΒΉ |
π² | π² | π² |
catch |
β
Async.CatchΒ² |
π² | π² | π² |
ofTask |
β
Async.AwaitTaskΒ³ |
π²β΄ | β | π² |
ofTaskUnit |
β
Async.AwaitTaskΒ³ |
π²β΄ | π² | π² |
ofValueTask |
β | π² | π² | β |
ofValueTaskUnit |
β | π² | π² | β |
NOTE the above are all functions on the basis that the additions are modules. TaskSeq uses a type with camelCased methods masquerading as functions in order to faciliate availing of overloading. If this approach was to be taken, ofTask/ofTaskUnit would collapse to an overloaded ofTask, and similarly for ofValueTask. There are pros and cons to this, but the assumption is that FSharp.Core should not introduce such a baroque mechanism without significant justification.
Notes
ΒΉ Async.Ignore exists (PascalCase); see Appendix A.
Β² Async.Catch exists but returns Async<Choice<'T, exn>>; Async.catch returns Async<Result<'T, exn>>. See Appendix A.
Β³ Async.AwaitTask exists but has incorrect AggregateException unwrapping; see Appendix A.
β΄ ofTask/ofTaskUnit are the sole behaviour change in this proposal β AwaitTaskCorrect semantics. See Appendix A.
β΅ ofAsync conversions are excluded: Async<'T> carries an implicit CancellationToken that must be considered explicitly when 'converting' to Task/ValueTask. Use Async.StartAsTask/Async.StartImmediateAsTask with an explicit token instead.
module Async =
val inline singleton : value: 'T -> Async<'T>
val inline map : mapping: ('T -> 'U) -> source: Async<'T> -> Async<'U>
val inline bind : binder: ('T -> Async<'U>) -> source: Async<'T> -> Async<'U>
val inline ignore : source: Async<'T> -> Async<unit>
// Error channel β catch as Result
val inline catch : source: Async<'T> -> Async<Result<'T, exn>>
// Task β Async with correct AggregateException unwrapping (Async.Await aka AwaitTaskCorrect semantics)
val ofTask : source: Task<'T> -> Async<'T>
val ofTaskUnit : source: Task -> Async<unit>
// ValueTask β Async (same AwaitTaskCorrect semantics)
val ofValueTask : source: ValueTask<'T> -> Async<'T>
val ofValueTaskUnit : source: ValueTask -> Async<unit>
module Task =
val inline singleton : value: 'T -> Task<'T>
val inline map : mapping: ('T -> 'U) -> source: Task<'T> -> Task<'U>
val inline bind : binder: ('T -> Task<'U>) -> source: Task<'T> -> Task<'U>
// Discard result, propagate exceptions and cancellation
val inline ignore : source: Task<'T> -> Task
val inline catch : source: Task<'T> -> Task<Result<'T, exn>>
// Task β Task<'T>
val inline ofTaskUnit : source: Task -> Task<unit>
// ValueTask β Task
val inline ofValueTask : source: ValueTask<'T> -> Task<'T>
val inline ofValueTaskUnit: source: ValueTask -> Task
module ValueTask =
val inline singleton : value: 'T -> ValueTask<'T>
val inline map : mapping: ('T -> 'U) -> source: ValueTask<'T> -> ValueTask<'U>
val inline bind : binder: ('T -> ValueTask<'U>) -> source: ValueTask<'T> -> ValueTask<'U>
// Discard result, propagate exceptions
val inline ignore : source: ValueTask<'T> -> ValueTask
val inline catch : source: ValueTask<'T> -> ValueTask<Result<'T, exn>>
// Task β ValueTask
val inline ofTask : source: Task<'T> -> ValueTask<'T>
val inline ofTaskUnit: source: Task -> ValueTask- Eliminates ecosystem-wide copy-paste of a well-understood, small set of helpers.
- Makes
TaskandValueTaskfirst-class citizens alongsideAsyncin the module-function style. - All new functions are zero-new-semantics (pure delegation to existing CE machinery), except the intentional
ofTask* fixes. - The backward-compatibility strategy (see Appendix A) is fully binary-compatible: no existing code breaks.
- Fixes the
AwaitTaskCorrectproblem (#840) officially, in a discoverable location.
catchreturningResult<'T, exn>vsChoice<'T, exn>β theResultform is idiomatic post-F# 4.1 but is a different type fromAsync.Catch.[<Browsable(false)>]suppresses IDE visibility but does not produce a call-site warning; a separate[<Obsolete>]pass could follow in a later release.
S / M β Implementations are all known-correct and proven in the ecosystem. The main effort is agreeing on the API shape and updating SurfaceArea baselines.
- fslang-suggestions #840 β add
AwaitTaskCorrectto FSharp.Core - FSharp.Control.TaskSeq #128 β Task.ignore / Async.ignore
- FSharp.Control.TaskSeq #140 β module Async.ignore / Task.ignore / ValueTask.ignore
- This is not a question; I have searched StackOverflow and this issue tracker.
- I have searched open and closed suggestions and do not believe this is a duplicate (closest prior: #840).
- This is not a breaking change to the F# language design.
- I or my company would be willing to help implement and/or test this.
FSharp.Core already exposes several operations on Async<'T> as PascalCase static members on the Async type, not least because it predated the mainstreaming of a module accompanying a type.
| Existing member | Issue |
|---|---|
Async.Ignore |
PascalCase; inconsistent with all other F# module functions |
Async.Catch |
PascalCase; returns Async<Choice<'T, exn>> β Choice predates Result |
Async.AwaitTask (Γ 2) |
PascalCase; incorrect AggregateException unwrapping; does not honor cancellation (see below) |
The migration strategy is:
- Add the new camelCase module functions as the canonical API going forward.
- Annotate the old PascalCase static members with
[<System.ComponentModel.BrowsableAttribute(false)>]so they disappear from IDE completion and documentation browsers while remaining fully binary-compatible.
Existing code using Async.Ignore, Async.Catch, or Async.AwaitTask continues to compile without change; it simply stops being the recommended path. A subsequent [<Obsolete>] annotation pass can follow at a later release boundary (potentially adding a warning or error about Async.AwaitTask).
Async.AwaitTask re-raises the outermost AggregateException on task failure, so the caller sees an AggregateException rather than the original inner exception. This is widely considered a bug, tracked in fslang-suggestions #840. The workaround β copying the AwaitTaskCorrect snippet from fssnip β is present in dozens of production codebases.
Note additionally, this implementation honors cancellation of the controlling Async via it's ambient CancellationToken (see TODO add ref to ticket)
The reference implementations of Async.ofTask and Async.ofTaskUnit address this. The corrected logic lives in a new pair of Async.Await overloads added alongside the existing Async.AwaitTask members and similarly marked [<System.ComponentModel.BrowsableAttribute(false)>]. The module-level functions simply delegate to them:
// New on the Async type β [<BrowsableAttribute(false)>] β holds the AwaitTaskCorrect logic
static member Await(task: Task<'T>) : Async<'T> =
Async.FromContinuations(fun (sc, ec, _cc) ->
task.ContinueWith(fun (t: Task<'T>) ->
if t.IsFaulted then
let e = t.Exception
if e.InnerExceptions.Count = 1 then ec e.InnerExceptions[0]
else ec e
elif t.IsCanceled then ec (TaskCanceledException())
else sc t.Result)
|> ignore)
static member Await(task: Task) : Async<unit> =
Async.FromContinuations(fun (sc, ec, _cc) ->
task.ContinueWith(fun (t: Task) ->
if t.IsFaulted then
let e = t.Exception
if e.InnerExceptions.Count = 1 then ec e.InnerExceptions[0]
else ec e
elif t.IsCanceled then ec (TaskCanceledException())
else sc ())
|> ignore)
// Module-level functions delegate to the above
module Async =
let ofTask (task: Task<'T>) : Async<'T> = Async.Await(task)
let ofTaskUnit (task: Task) : Async<unit> = Async.Await(task)All other functions in this proposal delegate to CE builder methods and need no separate implementation notes. ValueTask.ignore is the exception because it must follow Stephen Toub's guidance to avoid materialising a Task allocation (but still observe the Result) when the ValueTask is already synchronously complete:
let inline ignore (vt: ValueTask<'T>) =
if vt.IsCompletedSuccessfully then
vt.Result |> ignore // consume result to ensure any side-effects execute
ValueTask()
else
ValueTask(vt.AsTask())Abbreviations: A<T> = Async<'T>, T<T> = Task<'T>, VT<T> = ValueTask<'T>, T = Task, VT = ValueTask, R<T> = Result<'T,exn>, β = not in this module.
| Function | module Async |
module Task |
module ValueTask |
|---|---|---|---|
singleton |
'T -> A<T> |
'T -> T<T> |
'T -> VT<T> |
map |
('T->'U) -> A<T> -> A<U> |
('T->'U) -> T<T> -> T<U> |
('T->'U) -> VT<T> -> VT<U> |
bind |
('T->A<U>) -> A<T> -> A<U> |
('T->T<U>) -> T<T> -> T<U> |
('T->VT<U>) -> VT<T> -> VT<U> |
ignore |
A<T> -> A<unit> |
T<T> -> T |
VT<T> -> VT |
catch |
A<T> -> A<R<T>> |
T<T> -> T<R<T>> |
VT<T> -> VT<R<T>> |
ofTask |
T<T> -> A<T> |
β |
T<T> -> VT<T> |
ofTaskUnit |
T -> A<unit> |
T -> T<unit> |
T -> VT |
ofValueTask |
VT<T> -> A<T> |
VT<T> -> T<T> |
β |
ofValueTaskUnit |
VT -> A<unit> |
VT -> T |
β |