F# Result Type: Making Expected Failures Part of Your Function Signature
Use F#'s Result<'T,'Error> discriminated union and combinators like Result.bind, Result.map, and Result.mapError to represent domain failures explicitly instead of throwing exceptions, with a worked example and practical limits.
25 Nov 2025, 20:00 UTC

When a function can fail for an expected reason—invalid input, a missing record, a business-rule violation—throwing an exception hides that outcome from the type signature. Callers cannot see from the function's type whether they must handle a failure, and the compiler cannot enforce it. The useful takeaway is to return Result<'T,'Error> instead: a discriminated union that puts both success and failure directly into the function's contract, and then compose those results with Result.bind, Result.map, and Result.mapError so the failure path is visible and forced.
The mechanism: a discriminated union for two outcomes
In F#, a discriminated union is a type that can be one of several named cases. Result<'T,'Error> is defined in FSharp.Core as:
type Result<'T,'Error> =
| Ok of 'T
| Error of 'Error
Because both cases are part of the type, any function that returns Result announces that it can fail. The caller sees the error type in the signature and must pattern-match or use a combinator to proceed.
A worked example: validation and parsing
Suppose you accept a query string, trim it, and parse it as an integer. Each step can fail for a different reason. Instead of throwing, return Result:
let validate (q: string) =
if String.IsNullOrWhiteSpace q then Error "empty"
else Ok q
let parse (s: string) =
match Int32.TryParse s with
| true, n -> Ok n
| _ -> Error "not a number"
Compose them with the forward pipe and Result.bind:
let flow = validate >> Result.bind parse
Evaluating flow "42" produces Ok 42; evaluating flow "" produces Error "empty". The error channel carries a string here, but it can be any type—a custom union, an exception object, or a structured record.
Composing pipelines with bind, map, and mapError
Result.bind takes a function that returns a Result and a Result value, chaining them so the next step runs only on Ok. Its argument order is function first, value second, which means pipelines read right-to-left:
let flow =
validate
>> Result.bind parse
>> Result.map (fun n -> n * 2)
Result.map transforms the success value without touching the error path. Result.mapError does the opposite: it transforms the error value and leaves Ok unchanged:
let flow =
validate
>> Result.bind parse
>> Result.mapError (fun e -> sprintf "Validation failed: %s" e)
Because mapError only runs on Error, applying it to an Ok value returns the original Ok. That is usually correct, but it can mask a logic error if you expected every path to be transformed.
Converting Result at the system boundary
Keep the core of your application pure by returning Result everywhere. Convert to a transport-specific response once, at the outer edge—an HTTP handler, a CLI command, or an actor loop—with a single match:
match flow input with
| Ok n -> printfn "Success: %d" n
| Error msg -> printfn "Error: %s" msg
This keeps error formatting and status-code decisions in one place instead of scattering them through business logic.
Limits and common mistakes
- Not for truly exceptional states. Out-of-memory, disk I/O failures, or network interruptions are not expected domain outcomes. Exceptions remain appropriate there because they carry stack traces and unwind the stack.
- Allocation in hot loops.
Resultis a reference-type discriminated union, so everyOkorErrorallocates. Profile before using it inside tight numeric or parsing inner loops where allocation pressure matters. - Mixing exceptions and Result inconsistently. If some layers throw and others return
Result, callers face double error handling and lost context. Pick one policy per boundary and document it. - Argument order of Result.bind. It takes the function first, then the
Resultvalue. In a>>chain this reads right-to-left, which surprises developers used to left-to-right pipelines. - mapError on Ok values.
Result.mapErroronly transforms the error channel. Applied to anOkit returns theOkunchanged, which can hide a bug if you assumed it ran on every path. - Async and task nesting. Wrapping asynchronous work multiplies the container:
Result<'T,'E> of Async<'T>orAsync<Result<'T,'E>>. Choose one nesting order and keep it consistent across the codebase.
Verifying the behavior in your SDK
The Result type and the core combinators Result.bind, Result.map, and Result.mapError have been stable in FSharp.Core for many releases. Newer conveniences such as the result computation expression and resumable task interop depend on the SDK version and the LangVersion element in your .fsproj. Pin the SDK you ship with and check the compiler before relying on them.
To confirm the example locally, create a scratch console project in a terminal (no elevated permissions required):
dotnet new console -lang f# -o result-check
cd result-check
Paste the validate, parse, and flow definitions into Program.fs, add a match that prints the outcome, and run dotnet run. The expected check is that the program prints the success case for a valid integer and the error case for an empty or non-numeric string.
Alternatively, run the snippet in F# interactive with dotnet fsi, evaluate flow "42", and inspect the type with typeof<Result<int,string>> to see the union shape. Check the active SDK with dotnet --version and inspect the LangVersion in the project file. The risk of skipping this check is that newer syntax may fail to compile on an older toolchain.
Finally, lock the behavior in with unit tests that assert both Ok and Error branches, including the case where Result.mapError is applied to an Ok value, so future SDK upgrades do not silently change the contract.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.