Warning
API Still stabilising - wait for 1.0 to avoid breaking changes
FsFlow is a single model for Result-based programs in F#.
Write small predicate checks with Check, keep fail-fast logic in standard Result, accumulate sibling
validation with Validation and validate {}, then lift the same logic into Flow,
AsyncFlow, or TaskFlow when the boundary needs environment access, async work, task interop,
or runtime policy.
FsFlow is built around one progression:
Check -> Result -> Validation -> Flow -> AsyncFlow -> TaskFlow
The same validation vocabulary stays the same while the execution context grows.
- Start with
Checkfor reusable predicates. - Use standard
Resultvalues andresult {}for fail-fast pure code. - Use
Validationandvalidate {}when sibling failures should accumulate. - Use
flow {}when the boundary needs typed failure and environment, but not async runtime. - Use
asyncFlow {}when the boundary is naturallyAsync. - Use
taskFlow {}when the boundary is naturally.NET Task. - Keep expected failures typed all the way through instead of switching helper families at each runtime shape.
This is the key difference from split models like Result, Async<Result<_,_>>, and Task<Result<_,_>>
that need separate helper modules, separate builders, and repeated adaptation at the boundary.
FsFlowforFlow,AsyncFlow,TaskFlow, and the supporting validation/runtime helpers
Start with a reusable check and a fail-fast result:
open System.Threading.Tasks
open FsFlow
type RegistrationError =
| EmailMissing
| SaveFailed of string
let validateEmail (email: string) : Result<string, RegistrationError> =
email
|> Check.notBlank
|> Check.orError EmailMissingUse the same validation logic directly inside a task-oriented workflow:
open System.Threading.Tasks
open FsFlow
type User =
{ Email: string }
type RegistrationEnv =
{ LoadUser: int -> Task<Result<User, RegistrationError>>
SaveUser: User -> Task<Result<unit, RegistrationError>> }
let registerUser userId : TaskFlow<RegistrationEnv, RegistrationError, unit> =
taskFlow {
let! loadUser = TaskFlow.read _.LoadUser
let! saveUser = TaskFlow.read _.SaveUser
let! user = loadUser userId
do! validateEmail user.Email
return! saveUser user
}validateEmail is just a plain Result<string, RegistrationError>.
taskFlow lifts it directly with do!.
The task surface ships in the main FsFlow package and the FsFlow namespace.
FsFlow is for short-circuiting, ordered workflows:
Check,Result,Flow,AsyncFlow, andTaskFlowstop on the first typed failure.Validationandvalidate {}accumulate sibling failures in a structured diagnostics graph.- The flow families are for orchestration, dependency access, async or task execution, and runtime concerns.
If you need accumulated validation, use Validation and validate {} explicitly instead of
trying to hide it inside a flow builder.
FsFlow stays close to standard F# and .NET:
flow { ... }binds toResultandOptionasyncFlow { ... }also binds toAsync,Async<Option<_>>,Async<ValueOption<_>>, andAsync<Result<_,_>>taskFlow { ... }is the .NET task sibling and binds toTask,ValueTask,Task<_>,ValueTask<_>, andColdTaskresult {}keeps fail-fast pure code readablevalidate {}keeps sibling validation accumulation explicit
Because tasks are hot, FsFlow includes ColdTask: a small wrapper around CancellationToken -> Task.
taskFlow handles token passing for you and keeps reruns explicit.
This is the file-oriented example shape. The full runnable example is in
examples/FsFlow.ReadmeExample/Program.fs.
dotnet run --project examples/FsFlow.ReadmeExample/FsFlow.ReadmeExample.fsproj --nologoSupporting types in the full example are just:
ReadmeEnv = { Root: string }FileReadError = NotFound
let readTextFile (path: string) : TaskFlow<ReadmeEnv, FileReadError, string> =
taskFlow {
// In production, map access and path exceptions separately at the boundary.
do! okIf (File.Exists path) |> orElse (NotFound path) // from Validate
return! ColdTask(fun ct -> File.ReadAllTextAsync(path, ct)) // ColdTask<string>
}
let program : TaskFlow<ReadmeEnv, FileReadError, string * string> =
taskFlow {
let! root = TaskFlow.read _.Root // ReadmeEnv.Root -> string
let settingsFile = Path.Combine(root, "settings.json")
let featureFlagsFile = Path.Combine(root, "feature-flags.json")
let! settings = readTextFile settingsFile // TaskFlow<ReadmeEnv, FileReadError, string>
let! featureFlags = readTextFile featureFlagsFile // TaskFlow<ReadmeEnv, FileReadError, string>
return settings, featureFlags // TaskFlow<ReadmeEnv, FileReadError, string * string>
}It reads Root from 'env, performs two file reads in one taskFlow {}, and keeps failure typed at the boundary.
- Docs site for guides and API reference
docs/VALIDATE_AND_RESULT.mdfor the validation-first storyexamples/for runnable repo examplesdocs/TINY_EXAMPLES.mdfor the smallest runnable snippets