Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/release-notes/.FSharp.Core/11.0.100.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,8 @@
* Fix `Array.exists2` documentation examples to use equal-length arrays; the previous examples would throw `ArgumentException` at runtime instead of returning the documented `false`/`true` values. ([PR #19672](https://github.com/dotnet/fsharp/pull/19672))
* Move `Async.StartChild` to the "Starting Async Computations" docs category alongside `Async.StartChildAsTask`. ([Issue #19667](https://github.com/dotnet/fsharp/issues/19667))
* Add `InlineIfLambda` to `Array.init` ([PR #19869](https://github.com/dotnet/fsharp/pull/19869))

### Added

* Added modules for `Async`, `Task` and `ValueTask` with consistent `result`, `map`, `bind`, `ignore`, `catchWith`, `catch`, and `empty` functions ([LanguageSuggestion #1466](https://github.com/fsharp/fslang-suggestions/issues/1466), [PR #19844](https://github.com/dotnet/fsharp/pull/19844))
* Added conversion functions `Task.ofValueTask` and `ValueTask.ofTask`. ([LanguageSuggestion #1466](https://github.com/fsharp/fslang-suggestions/issues/1466), [PR #19844](https://github.com/dotnet/fsharp/pull/19844))
42 changes: 42 additions & 0 deletions src/FSharp.Core/async.fs
Original file line number Diff line number Diff line change
Expand Up @@ -2355,3 +2355,45 @@ module WebExtensions =
start = (fun userToken -> this.DownloadFileAsync(address, fileName, userToken)),
result = (fun _ -> ())
)

[<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>]
Comment thread
bartelink marked this conversation as resolved.
module Async =

[<CompiledName("Result")>]
let inline result (value: 'T) : Async<'T> =
async.Return value

[<CompiledName("Map")>]
let inline map ([<InlineIfLambda>] mapping: 'T -> 'U) (computation: Async<'T>) : Async<'U> =
async.Bind(computation, mapping >> async.Return)

[<CompiledName("Bind")>]
let inline bind ([<InlineIfLambda>] binder: 'T -> Async<'U>) (computation: Async<'T>) : Async<'U> =
async.Bind(computation, binder)

[<CompiledName("Ignore")>]
[<RequiresExplicitTypeArguments>]
let inline ignore<'T> (computation: Async<'T>) : Async<unit> =
Async.Ignore computation

[<CompiledName("CatchWith")>]
let catchWith (handler: exn -> 'T) (computation: Async<'T>) : Async<'T> =
async {
try
return! computation
with e ->
return handler e
}

[<CompiledName("Catch")>]
let catch (computation: Async<'T>) : Async<Result<'T, exn>> =
async {
try
let! v = computation
return Result.Ok v
with e ->
return Result.Error e
}

[<CompiledName("Empty")>]
let empty: Async<unit> = async.Zero()
121 changes: 120 additions & 1 deletion src/FSharp.Core/async.fsi
Original file line number Diff line number Diff line change
Expand Up @@ -970,7 +970,7 @@ namespace Microsoft.FSharp.Control
/// use file = System.IO.File.OpenRead(filename)
/// printfn "Reading from file %s." filename
/// // Throw away the data being read.
/// do! file.AsyncRead(numBytes) |> Async.Ignore
/// do! file.AsyncRead(numBytes) |> Async.ignore&lt;byte[]&gt;
/// }
/// readFile "example.txt" 42 |> Async.Start
/// </code>
Expand Down Expand Up @@ -1548,3 +1548,122 @@ namespace Microsoft.FSharp.Control
module internal AsyncBuilderImpl =
val async : AsyncBuilder

/// <summary>Contains camelCase module-level functions for <see cref="T:Microsoft.FSharp.Control.FSharpAsync`1"/> computations.</summary>
///
/// <category index="1">Async Programming</category>
[<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>]
module Async =

/// <summary>Creates an asynchronous computation that returns the given value.</summary>
///
/// <param name="value">The value to return.</param>
///
/// <returns>An asynchronous computation that returns <c>value</c> when executed.</returns>
///
/// <example id="async-result-1">
/// <code lang="fsharp">
/// let computation = Async.result 42
/// computation |> Async.RunSynchronously // evaluates to 42
/// </code>
/// </example>
[<CompiledName("Result")>]
val inline result: value: 'T -> Async<'T>

/// <summary>Creates an asynchronous computation that applies the mapping function to the result of the given computation.</summary>
///
/// <param name="mapping">The function to apply to the result.</param>
/// <param name="computation">The input computation.</param>
///
/// <returns>An asynchronous computation that applies <c>mapping</c> to the result of <c>computation</c>.</returns>
///
/// <example id="async-map-1">
/// <code lang="fsharp">
/// let computation = Async.result 21 |> Async.map (fun x -> x * 2)
/// computation |> Async.RunSynchronously // evaluates to 42
/// </code>
/// </example>
[<CompiledName("Map")>]
val inline map: mapping: ('T -> 'U) -> computation: Async<'T> -> Async<'U>

/// <summary>Creates an asynchronous computation that passes the result of the given computation to the binder function.</summary>
///
/// <param name="binder">A function that takes the result of the computation and returns a new asynchronous computation.</param>
/// <param name="computation">The input computation.</param>
///
/// <returns>An asynchronous computation that performs a monadic bind on the result of <c>computation</c>.</returns>
///
/// <example id="async-bind-1">
/// <code lang="fsharp">
/// let computation = Async.result 21 |> Async.bind (fun x -> Async.result (x * 2))
/// computation |> Async.RunSynchronously // evaluates to 42
/// </code>
/// </example>
[<CompiledName("Bind")>]
val inline bind: binder: ('T -> Async<'U>) -> computation: Async<'T> -> Async<'U>

/// <summary>Creates an asynchronous computation that runs the given computation and ignores its result.</summary>
///
/// <param name="computation">The input computation.</param>
///
/// <returns>A computation that is equivalent to the input computation, but disregards the result.</returns>
///
/// <example id="async-ignore-1">
/// <code lang="fsharp">
/// let readFile filename numBytes =
/// async {
/// use file = System.IO.File.OpenRead(filename)
/// do! file.AsyncRead(numBytes) |> Async.ignore&lt;byte[]&gt;
/// }
/// </code>
/// </example>
[<CompiledName("Ignore")>]
[<RequiresExplicitTypeArguments>]
val inline ignore<'T> : computation: Async<'T> -> Async<unit>

/// <summary>Creates an asynchronous computation that runs the given computation.
/// If it raises an exception, the handler function is called with the exception and its result is returned.</summary>
///
/// <param name="handler">A function to handle exceptions, returning a recovery value.</param>
/// <param name="computation">The input computation.</param>
///
/// <returns>An asynchronous computation that returns the result of <c>computation</c>, or the result of <c>handler</c> if an exception is raised.</returns>
///
/// <example id="async-catchwith-1">
/// <code lang="fsharp">
/// let safeDiv x y =
/// async { return x / y }
/// |> Async.catchWith (fun _ -> 0)
/// safeDiv 10 0 |> Async.RunSynchronously // evaluates to 0
/// </code>
/// </example>
[<CompiledName("CatchWith")>]
val catchWith: handler: (exn -> 'T) -> computation: Async<'T> -> Async<'T>

/// <summary>Creates an asynchronous computation that runs the given computation and returns its result as <c>Ok</c>,
/// or returns <c>Error</c> with the exception if one is raised.</summary>
///
/// <param name="computation">The input computation.</param>
///
/// <returns>An asynchronous computation that returns <c>Ok</c> of the result or <c>Error</c> of the exception.</returns>
///
/// <example id="async-catch-1">
/// <code lang="fsharp">
/// let safeDiv x y =
/// async { return x / y } |> Async.catch
/// safeDiv 10 2 |> Async.RunSynchronously // evaluates to Ok 5
/// safeDiv 10 0 |> Async.RunSynchronously // evaluates to Error (DivideByZeroException ...)
/// </code>
/// </example>
[<CompiledName("Catch")>]
val catch: computation: Async<'T> -> Async<Result<'T, exn>>

/// <summary>An asynchronous computation that returns <c>unit</c>. This is equivalent to <c>async.Zero()</c>.</summary>
///
/// <example id="async-empty-1">
/// <code lang="fsharp">
/// Async.empty |> Async.RunSynchronously // evaluates to ()
/// </code>
/// </example>
[<CompiledName("Empty")>]
val empty: Async<unit>

164 changes: 164 additions & 0 deletions src/FSharp.Core/tasks.fs
Original file line number Diff line number Diff line change
Expand Up @@ -716,3 +716,167 @@ module LowPlusPriority =
this.Bind(computation, fun (result2: ^TResult2) -> this.Return struct (result1, result2))
)
)

namespace Microsoft.FSharp.Control

open System.Threading.Tasks
open Microsoft.FSharp.Core
open TaskBuilder
open Microsoft.FSharp.Control.TaskBuilderExtensions
open Microsoft.FSharp.Control.TaskBuilderExtensions.LowPriority
open Microsoft.FSharp.Control.TaskBuilderExtensions.HighPriority

[<RequireQualifiedAccess>]
[<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>]
module Task =

[<CompiledName("Result")>]
let inline result (value: 'T) : Task<'T> =
Task.FromResult value

[<CompiledName("Empty")>]
let empty: Task<unit> = result ()

[<CompiledName("Map")>]
let inline map ([<InlineIfLambda>] mapping: 'T -> 'U) (task: Task<'T>) : Task<'U> =
// if task.IsCompleted then // includes Canceled or Faulted states
// try result (task.GetAwaiter().GetResult() |> mapping) // Result would surface AggregateException
// with e -> Task.FromException<'U>(e)
// else
if task.Status = TaskStatus.RanToCompletion then
result (mapping task.Result)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

map/bind throw synchronously on already-completed input — also bind (:751) and the ValueTask equivalents (:809/:822).

let boom (_: int) : int = failwith "boom"
tcs.Task       |> Task.map boom     // pending
Task.result 21 |> Task.map boom     // completed
  • pending input → faulted Task
  • completed input → raises at the call site, no Task returned
  • same call, exception delivered two different ways depending on timing

Proposed fix:

if task.Status = TaskStatus.RanToCompletion then
    try result (mapping task.Result)
    with e -> Task.FromException<'U> e

@bartelink bartelink Jul 14, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@T-Gro I get the point/concern on the catch variants - easy to test etc and will fix.

same call, exception delivered two different ways depending on timing

But can you clarify the exact desire re cancellation handling please?

This code will use the task { leg in the canceled or faulted case and passes my tests:

if task.Status = TaskStatus.RanToCompletion then
    result (mapping task.Result)
else
    task {
        let! v = task
        return mapping v
    }

Recall task.Status = TaskStatus.RanToCompletion is a ns2.0 compatible equivalent of .IsCompletedSuccessfully, so AIUI there can't be a throw from that leg, so the proposed fix would never hit its catch?

Changing Status = TaskStatus.RanToCompletion fast path check to to instead use IsCompleted to force a given handling would entail switching to:

if task.IsCompleted then // includes Canceled or Faulted states
     try result (task.GetAwaiter().GetResult() |> mapping) // Result would surface AggregateException
     with e -> Task.FromException<'U>(e)
else
    TaskBuilder.task {
            let! v = task
            return mapping v
        }

Current (passing with above impls) test semantics:

[<Fact>]
let ``Task.map flows Cancellation (sync)`` () =
    use cts = new CancellationTokenSource()
    cts.Cancel()
    let t = Task.FromCanceled<int>(cts.Token) |> Task.map (fun x -> x * 2)
    task {
        let! e = Assert.ThrowsAsync<TaskCanceledException>(fun () -> t)
        Assert.Equal(cts.Token, e.CancellationToken)
    }
    
[<Fact>]
let ``Task.map flows Cancellation (async)`` () =
    let tcs = TaskCompletionSource<int>()
    let t = tcs.Task |> Task.map (fun x -> x * 2)
    use cts = new CancellationTokenSource()
    tcs.SetCanceled cts.Token
    task {
        let! e = Assert.ThrowsAsync<TaskCanceledException>(fun () -> t)
        Assert.Equal(cts.Token, e.CancellationToken)
    }
    
[<Fact>]
let ``Task.map propagates exception (sync)`` () =
    let t = Task.FromException<int>(Exception "boom") |> Task.map (fun x -> x * 2)
    task {
        let! e = Assert.ThrowsAnyAsync<exn>(fun () -> t)
        Assert.Equal("boom", e.Message)
    }

[<Fact>]
let ``Task.map propagates exception (async)`` () =
    let tcs = TaskCompletionSource<int>()
    let t = tcs.Task |> Task.map (fun x -> x * 2)
    tcs.SetException(exn "boom")
    task {
        let! e = Assert.ThrowsAnyAsync<exn>(fun () -> t)
        Assert.Equal("boom", e.Message)
    }

How would one specify/validate the precise semantics you seek? Is it about being able to set a breakpoint?

Anything worth borrowing from prior art?:

https://github.com/fsprojects/FSharpPlus/blob/master/src/FSharpPlus/Extensions/Task.fs#L15-L28
https://github.com/fsprojects/FSharpPlus/blob/master/src/FSharpPlus/Extensions/Task.fs#L85-L96
https://github.com/demystifyfp/FsToolkit.ErrorHandling/blob/master/src/FsToolkit.ErrorHandling/Task.fs#L8-L36

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cc @TheAngryByrd @gusty If either of you have time to throw a set of eyes over the impl and the test suite to see if there are any gaps that FsToolkit and/or FSharpPlus cover which should be considered?

https://github.com/bartelink/fsharp/blob/atvt/src/FSharp.Core/tasks.fs#L729-L804
https://github.com/bartelink/fsharp/blob/atvt/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Control/TaskModuleFunctions.fs#L11-L274

The aim is for a balance of:

  • idiomatic / terse impl (optimization can come later (though each function has a sync/completed fast path)
  • thorough test suite that provides coverage of all intended behaviors, no matter how esoteric (i.e. if this provides bad stack traces and/or usage of return raise e throws away stack traces and the test suite should call that out, I'm interested!

Bottom line it would be good to rule out footguns like egregious AggregateException wrapping or catch/catchWith trapping cancellation from the off

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@T-Gro Task test suite reviewed, expanded and polished
catch/catchWith Cancellation handling is corrected

NOTE ValueTask impl and tests are still unchanged - I'll port those when we're happy with Task.

else
TaskBuilder.task {
let! v = task
return mapping v
}

[<CompiledName("Bind")>]
let inline bind ([<InlineIfLambda>] binder: 'T -> Task<'U>) (task: Task<'T>) : Task<'U> =
if task.Status = TaskStatus.RanToCompletion then
binder task.Result
else
TaskBuilder.task {
let! v = task
return! binder v
}

[<CompiledName("Ignore")>]
[<RequiresExplicitTypeArguments>]
let inline ignore<'T> (task: Task<'T>) : Task<unit> =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let inline ignore<'T> (task: Task<'T>) : Task<unit> =
let inline ignore (task: Task) : Task<unit> =

ignore doesn't accept Task (non generic).

The implementation in F#+ allows to convert a Task to a Task<unit> and I would say in 50% of the scenarios is used in this way. I think we already discussed this.

@bartelink bartelink Jul 14, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm problem is that people want RequiresExplicitTypeArguments [and hence generic] here
also it has to be a module so no overloading
Would not want to do a one-off ugly name unless there are other cases...
Maybe naming it Task.ofTask (as the inital proposal suggested) would work
But I'm also betting that in the kind of codebase requiring heavy TPL interop this would be only the start of the conversion helpers that would be useful but deep in the long tail in terms of usage count?

(Best discussed in fslang-sugggestion)

if task.Status = TaskStatus.RanToCompletion then
empty
else
map ignore task

[<CompiledName("CatchWith")>]
let inline catchWith ([<InlineIfLambda>] handler: exn -> 'T) (task: Task<'T>) : Task<'T> =
Comment thread
bartelink marked this conversation as resolved.
if task.Status = TaskStatus.RanToCompletion then
task
else
TaskBuilder.task {
try
return! task
with
| :? System.OperationCanceledException as e -> return! raise e
| e -> return handler e
}

[<CompiledName("Catch")>]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

catch demotes cancellation to Error — also ValueTask.catch (:860). Reverses the shipped Error(cancelled) test; decision is to treat cancellation as first-class, consistent with Async.

Task.FromCanceled<int>(CancellationToken true) |> Task.catch
  • result is Error (TaskCanceledException)
  • Async.catch propagates; Error should be reserved for genuine faults

Proposed fix:

TaskBuilder.task {
    try
        let! v = task
        return Ok v
    with
    | :? OperationCanceledException as e -> return raise e   // stays Canceled
    | e -> return Error e
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(corrected; pushed for Task, but not yet for ValueTask)
Thanks for the catch, the proposed fix works (though I wonder if the stack trace is optimal and/or whether that's the canonical way to bail on cancellation)

@T-Gro I guess map, bind, catch, catchWith should each have xmldoc covering the pinned behavior? i.e. I'm thinking that it should allude to the fact that catch will let a TaskCanceledException escape so it's not 100% 1:1 equivalent to task { try let! r = task in Ok r with e -> Error e }

let catch (task: Task<'T>) : Task<Result<'T, exn>> =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we kind of agreed that this function shouldn't be included for the reasons mentioned in the discussion.

I'm not pretending to restart the discussions, but just looking at the votes. There are 3 "really don't want" against 1 "really want", plus 2 "I'm ok with it", there are more against that in favor.

@bartelink bartelink Jul 14, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

empty was more lukewarm and I'd venture higher priority to debate...
As my vote indicates, I'd personally prefer it not to be there, but there are people who think it's an important thing to have in the box... The place to debate this this is probably out in the fslang-suggestion though

if task.Status = TaskStatus.RanToCompletion then
result (Ok task.Result)
else
TaskBuilder.task {
try
let! v = task
return Ok v
with
| :? System.OperationCanceledException as e -> return! raise e
| e -> return Error e
}
Comment thread
bartelink marked this conversation as resolved.

#if NETSTANDARD2_1
[<CompiledName("OfValueTask")>]
let inline ofValueTask (valueTask: ValueTask<'T>) : Task<'T> =
valueTask.AsTask()
#endif

#if NETSTANDARD2_1
[<RequireQualifiedAccess>]
[<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>]
module ValueTask =

[<CompiledName("Result")>]
let inline result (value: 'T) : ValueTask<'T> =
ValueTask<'T>(value)

[<CompiledName("Map")>]
let inline map ([<InlineIfLambda>] mapping: 'T -> 'U) (task: ValueTask<'T>) : ValueTask<'U> =
if task.IsCompletedSuccessfully then
ValueTask<'U>(mapping task.Result)
else
let t: Task<'U> =
TaskBuilder.task {
let! v = task
return mapping v
}

ValueTask<'U>(t)

[<CompiledName("Bind")>]
let inline bind ([<InlineIfLambda>] binder: 'T -> ValueTask<'U>) (task: ValueTask<'T>) : ValueTask<'U> =
if task.IsCompletedSuccessfully then
binder task.Result
else
let t: Task<'U> =
TaskBuilder.task {
let! v = task
return! binder v
}

ValueTask<'U>(t)

[<CompiledName("Ignore")>]
[<RequiresExplicitTypeArguments>]
let inline ignore<'T> (task: ValueTask<'T>) : ValueTask<unit> =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as for the Task version

map ignore task

[<CompiledName("CatchWith")>]
let inline catchWith ([<InlineIfLambda>] handler: exn -> 'T) (task: ValueTask<'T>) : ValueTask<'T> =
if task.IsCompletedSuccessfully then
task
else
let t: Task<'T> =
TaskBuilder.task {
try
return! task
with e ->
return handler e
}

ValueTask<'T>(t)

[<CompiledName("Catch")>]
let catch (task: ValueTask<'T>) : ValueTask<Result<'T, exn>> =
if task.IsCompletedSuccessfully then
ValueTask<Result<'T, exn>>(Ok task.Result)
else
let t: Task<Result<'T, exn>> =
TaskBuilder.task {
try
let! v = task
return Ok v
with e ->
return Error e
}

ValueTask<Result<'T, exn>>(t)

[<CompiledName("Empty")>]
let empty: ValueTask<unit> = result ()

[<CompiledName("OfTask")>]
let inline ofTask (task: Task<'T>) : ValueTask<'T> =
ValueTask<'T>(task)
#endif
Loading
Loading