Warning
API Still stabilising - wait for 1.0 to avoid breaking changes
FsFlow provides structured composition over normal F#/.NET code. It is a coherent application architecture model for F# on .NET, centered on a unified effect system.
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
when the boundary needs environment access, async work, task interop, or runtime policy.
FsFlow is built around one progression:
Check -> Result -> Validation -> Flow
The same vocabulary stays the same while the execution context grows.
- Structured Composition: A single
flow {}builder that bindsResult,Option,Async,Task, andColdTaskdirectly, eliminating the "adapter tax" of switching helper families at every boundary. - Architectural Honesty: Keep dependencies explicit in 'env - but allow integration with IServiceProvider at the boundary.
- ZIO-Style Execution: Preserves the critical distinction between typed domain failures, cancellations, and unhandled defects.
- Composable State: Built-in Software Transactional Memory (STM) for atomic coordination across multiple variables without manual lock management.
Lets start by showing a reusable check and a fail-fast result:
type RegistrationError =
| EmailMissing
| SaveFailed of string
let validateEmail (email: string) : Result<string, RegistrationError> =
email
|> Check.whenNotBlank
|> Check.withError EmailMissingUse the same validation logic directly inside a task-oriented workflow:
type User =
{ Email: string }
type RegistrationEnv =
{ LoadUser: int -> Task<Result<User, RegistrationError>>
SaveUser: User -> Task<Result<unit, RegistrationError>> }
let registerUser userId : Flow<RegistrationEnv, RegistrationError, unit> =
flow {
let! loadUser = Flow.read _.LoadUser
let! saveUser = Flow.read _.SaveUser
let! user = loadUser userId
do! validateEmail user.Email
return! saveUser user
}validateEmail is just a plain Result<string, RegistrationError>.
flow lifts it directly with do!.
The same builder also binds Async, Task, ValueTask, and ColdTask directly.
FsFlow stays close to standard F# and .NET:
flow { ... }binds toResultandOptionflow { ... }also binds toAsync,Async<Option<_>>,Async<ValueOption<_>>, andAsync<Result<_,_>>- On .Net,
flow { ... }also binds toTask,ValueTask,Task<_>,ValueTask<_>, andColdTask result {}keeps fail-fast pure code readablevalidate {}keeps sibling validation accumulation explicit
Because tasks are hot, FsFlow includes ColdTask: a small wrapper around CancellationToken -> Task.
flow handles token passing for you and keeps reruns explicit.
The full runnable example is in examples/FsFlow.ReadmeExample/Program.fs.
dotnet run --project examples/FsFlow.ReadmeExample/FsFlow.ReadmeExample.fsproj// ReadmeEnv = { Root: string }
// FileReadError = NotFound
let readTextFile (path: string) : Flow<ReadmeEnv, FileReadError, string> =
flow {
// In production, map access and path exceptions separately at the boundary.
do! File.Exists path |> Check.isTrue |> BindError.withError (NotFound path)
// Wrap in ColdTask for later exeuction
return! ColdTask(fun ct -> File.ReadAllTextAsync(path, ct))
}
let program : Flow<ReadmeEnv, FileReadError, string * string> =
flow {
let! root = Flow.read _.Root // ReadmeEnv.Root -> string
let settingsFile = Path.Combine(root, "settings.json")
let featureFlagsFile = Path.Combine(root, "feature-flags.json")
let! settings = readTextFile settingsFile // Flow<ReadmeEnv, FileReadError, string>
let! featureFlags = readTextFile featureFlagsFile // Flow<ReadmeEnv, FileReadError, string>
return settings, featureFlags // Flow<ReadmeEnv, FileReadError, string * string>
}It reads Root from 'env, performs two file reads in one flow {}, and keeps failure typed at the boundary.