Skip to content

Instantly share code, notes, and snippets.

@bartelink
Last active April 25, 2026 18:54
Show Gist options
  • Select an option

  • Save bartelink/a19865472e5751f70210c263655676b4 to your computer and use it in GitHub Desktop.

Select an option

Save bartelink/a19865472e5751f70210c263655676b4 to your computer and use it in GitHub Desktop.
WIP for fslang suggestion to provide Async/Task/ValueTask helper modules

Add consistent helpers for Async/Task/ValueTask`

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.


Naming and implementation convention

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).


Status table

Icon Meaning
βœ… Exists in FSharp.Core today (may have known issues β€” see notes)
πŸ”² Proposed
βž– Not proposed / out of scope

Status

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.


Proposed signatures

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

Pros and cons

Advantages

  • Eliminates ecosystem-wide copy-paste of a well-understood, small set of helpers.
  • Makes Task and ValueTask first-class citizens alongside Async in 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 AwaitTaskCorrect problem (#840) officially, in a discoverable location.

Disadvantages / open questions

  • catch returning Result<'T, exn> vs Choice<'T, exn> β€” the Result form is idiomatic post-F# 4.1 but is a different type from Async.Catch.
  • [<Browsable(false)>] suppresses IDE visibility but does not produce a call-site warning; a separate [<Obsolete>] pass could follow in a later release.

Estimated cost

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.

Related

Affidavit

  • 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.

Appendix A: Backward-compatibility shims for existing Async members

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:

  1. Add the new camelCase module functions as the canonical API going forward.
  2. 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).

The "AwaitTaskCorrect" behaviour fix

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)

Appendix B: Reference implementation for ValueTask.ignore

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())

Appendix C: Full signature comparison

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 β€”

References / Prior art

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment