Repository F# setup
open System
open System.IO
open System.Threading
open System.Threading.Tasks
open Axial
open Axial.Layers
open Axial.Console
open Axial.FileSystem
open Axial.Hosting
open Axial.Hosting.Browser
open Axial.Hosting.Node
open Axial.PlatformService
open Axial.State
open Axial.Telemetry
open Axial.Telemetry.JavaScript
Typed asynchronous workflows for F#
Axial Axial

Make the safe path the easy path.

Flow is one type for application work that runs asynchronously, can fail, can be cancelled, and uses services. Axial's runtime cancels, cleans up, and retries that work by the same rules everywhere, and the type says what the work needs.

Run your first workflow Why Flow?

1

Async work and expected errors in one type

With Task<Result<_, _>>, every step matches the result before the next can run. In flow { }, let! stops at the first expected error, and an exception becomes a defect in the outcome instead of escaping. FsToolkit.ErrorHandling's taskResult { } removes the same nesting, but the result is still Task code, with cancellation and cleanup handled by hand. The next points cover those.

Task
task {
    match! loadCart id with
    | Error e -> return Error e
    | Ok cart ->
        match! charge cart with
        | Error e -> return Error e
        | Ok payment ->
            return Ok(receipt cart payment)
}
Flow
flow {
    let! cart = loadCart id
    let! payment = charge cart
    return receipt cart payment
}
2

Cancellation is handled the same way everywhere

A flow takes no cancellation token. When one of two parallel loads fails, the other is interrupted. When the time limit passes, both are, and what they acquired is released before the timeout returns. Forked work belongs to the flow that started it, so nothing outlives a cancelled request.

Task
use cts =
    CancellationTokenSource
        .CreateLinkedTokenSource token
cts.CancelAfter(TimeSpan.FromSeconds 2.0)
let profile = loadProfile user cts.Token
let orders = loadOrders user cts.Token
// one failing does not stop the other
do! Task.WhenAll(profile :> Task, orders)
Flow
Flow.zipPar
    (loadProfile user)
    (loadOrders user)
|> Flow.timeout
    (TimeSpan.FromSeconds 2.0)
    DashboardTimedOut
3

Dependencies in the signature mark the boundaries

A flow's first type parameter names the services it uses. reserveStock can reach inventory and nothing else, so the compiler shows where one area of the application calls into another, and a test supplies only what the flow names.

Task
// Which services does it use?
// The signature does not say.
val checkout:
    Cart -> CancellationToken
    -> Task<Result<Receipt, OrderError>>
Flow
val reserveStock: Cart ->
    Flow<Inventory, OrderError, Reservation>
val chargeCard: Cart ->
    Flow<Billing, OrderError, Payment>
val placeOrder: Cart ->
    Flow<Shop, OrderError, Receipt>
4

Time, randomness, and IDs are services too

Reading the clock or making a GUID adds that service to the flow's type, and the requirement carries up to every caller. The application supplies the live services; a test supplies fixed ones and gets the same result every run.

Task
// A different result on every run,
// and nothing in the type says why.
{ Id = Guid.NewGuid()
  PlacedAt = DateTimeOffset.UtcNow }
Flow
flow {
    let! placedAt = Clock.now
    let! id = Guid.newGuid
    return { Id = id; PlacedAt = placedAt }
}
// needs: 'env :> IHasClock and IHasGuid
5

Resources belong to their scope

A helper can open a connection and return it. It closes when the caller's scope ends, in reverse order, however the scope ends.

flow {
    let! orders = connect "orders"
    let! billing = connect "billing"
    return summarise orders billing
}
|> Flow.scoped
6

Streams keep the same rules

Four workers at a time; once ten pages arrive, the rest are interrupted and their resources released.

FlowStream.fromSeq pageIds
|> FlowStream.mapFlowPar
    (Parallelism.bounded 4) fetchPage
|> FlowStream.take 10
|> FlowStream.runCollect
7

You can see what is running

Every fiber has an id, a parent, and a name. A registry lists the live ones; telemetry turns them into spans and metrics.

poll
|> Flow.forkNamed "outbox-poller"
|> Flow.annotate "tenant" "acme"
|> Flow.withFiberRegistry registry
// registry.DumpAt(clock) lists live fibers
8

Fewer states to reason about, for people and for LLMs

Every flow ends in one of three ways. Concurrency follows fixed rules instead of per-call-site choices, and the build reports the shortcuts the types cannot rule out. A reviewer, or an LLM coding assistant, has fewer cases to consider and gets told about the usual mistakes.

Value Expected error, named in the type Defect or interruption
  • Forked work belongs to the scope that started it.
  • Cancellation always arrives as an interruption.
  • Resources are released in reverse order, however the scope ends.
  • Axial.Guardrails reports direct clock and randomness calls, exceptions raised in flow { }, and dropped cancellation tokens.
How it fits together
Typed environment
Application record
Clock
HTTP
File system
Process
Your services
Flow<'env, 'error, 'value>
Supplied at the edge
Live services
Test doubles
.NET
Browser
Node

A workflow names the environment it needs. The caller supplies the implementation when it runs the workflow.

Built on Axial

Process

Compose external commands and pipelines, stream output, and handle cancellation and failures through Flow.

Read the Process documentation →

HTTP

Build typed requests, handle responses, and apply reliability policies through the same workflow model.

Read the HTTP documentation →

View all packages →