Skip to content
AgileLogics
7 min read

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 Result for 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