Result and Error Handling
Prerequisites: Discriminated unions
Option says "there might be no value". Result says "this might fail
— and here's why". It's a built-in union:
type Result<'T, 'TError> =
| Ok of 'T
| Error of 'TError
let parseAge (input: string) =
match System.Int32.TryParse input with
| true, age when age >= 0 && age < 150 -> Ok age
| true, age -> Error $"{age} is not a realistic age"
| false, _ -> Error $"'{input}' is not a number"
for input in ["36"; "-4"; "abc"] do
match parseAge input with
| Ok age -> printfn "Valid age: %d" age
| Error message -> printfn "Invalid: %s" message
Valid age: 36
Invalid: -4 is not a realistic age
Invalid: 'abc' is not a number
The function's type, string -> Result<int, string>, tells every caller
that it can fail — and the compiler makes them handle both cases.
Errors as a union
Strings are fine to start with. For real applications, define your errors as a union so callers can react to each one:
type SignupError =
| EmptyName
| InvalidEmail of string
| TooYoung of age: int
let validateName (name: string) =
if name.Trim() = "" then Error EmptyName else Ok (name.Trim())
let validateEmail (email: string) =
if email.Contains "@" then Ok email else Error (InvalidEmail email)
let validateAge age =
if age >= 13 then Ok age else Error (TooYoung age)
printfn "%A" (validateName " ")
printfn "%A" (validateEmail "ada.example.com")
printfn "%A" (validateAge 30)
Error EmptyName
Error (InvalidEmail "ada.example.com")
Ok 30
Chaining with Result.bind
Result.map transforms a success value; Result.bind chains a step
that can itself fail. The first Error short-circuits the rest — this
style is often called railway-oriented programming: success stays
on the main track, and any failure switches to the error track.
let parseNumber (s: string) =
match System.Double.TryParse(s, System.Globalization.CultureInfo.InvariantCulture) with
| true, n -> Ok n
| false, _ -> Error $"not a number: {s}"
let nonNegative n = if n >= 0.0 then Ok n else Error "negative number"
let safeSqrt input =
input
|> parseNumber
|> Result.bind nonNegative
|> Result.map sqrt
for s in ["16"; "-9"; "ten"] do
printfn "%s -> %A" s (safeSqrt s)
16 -> Ok 4.0
-9 -> Error "negative number"
ten -> Error "not a number: ten"
Combining several results
To build a record from several validated fields, match on them together:
type User = { Name: string; Email: string; Age: int }
let validateName (n: string) = if n = "" then Error "name is required" else Ok n
let validateEmail (e: string) = if e.Contains "@" then Ok e else Error "email needs an @"
let validateAge a = if a >= 13 then Ok a else Error "must be 13 or older"
let createUser name email age =
match validateName name, validateEmail email, validateAge age with
| Ok n, Ok e, Ok a -> Ok { Name = n; Email = e; Age = a }
| results ->
let (r1, r2, r3) = results
[ r1 |> Result.map ignore
r2 |> Result.map ignore
r3 |> Result.map ignore ]
|> List.choose (function Error e -> Some e | Ok _ -> None)
|> Error
printfn "%A" (createUser "Ada" "ada@example.com" 36)
printfn "%A" (createUser "" "nope" 9)
Ok { Name = "Ada"
Email = "ada@example.com"
Age = 36 }
Error ["name is required"; "email needs an @"; "must be 13 or older"]
Unlike the bind chain, this collects all the errors — what you
want for form validation. Libraries such as FsToolkit.ErrorHandling
provide result { } and validation { } expressions that make both
styles shorter (Level 3 shows how such expressions work).
Result or exception?
- Use
Resultfor expected failures that are part of your domain: invalid input, a missing record, a rule that isn't met. - Use exceptions for unexpected failures: bugs, a disk that's gone, an out-of-memory error — things callers can't sensibly handle case by case.
Practice
Write withdraw amount balance that returns Error "amount must be positive", Error "insufficient funds", or Ok newBalance. Then chain
two withdrawals from a balance of 100 with Result.bind: 30, then 80.
Show solution
let withdraw amount balance =
if amount <= 0m then Error "amount must be positive"
elif amount > balance then Error "insufficient funds"
else Ok (balance - amount)
let result =
Ok 100m
|> Result.bind (withdraw 30m)
|> Result.bind (withdraw 80m)
printfn "%A" result
printfn "%A" (Ok 100m |> Result.bind (withdraw 30m) |> Result.bind (withdraw 50m))
Error "insufficient funds"
Ok 20M