You are viewing a preview of this lesson. Sign in to start learning
Back to Functional Programming with F#

Error Handling & Effects

Handle failures and side effects functionally using Option, Result types, and computation expressions for async and workflows.

Error Handling & Effects in F#

Master functional error handling with free flashcards and spaced repetition practice. This lesson covers discriminated unions for errors, the Result type, and effect managementβ€”essential concepts for writing robust, maintainable F# applications. You'll learn how to leverage pattern matching to handle errors elegantly while maintaining type safety and composability.

Welcome 🎯

Error handling is often where functional programming truly shines. Instead of relying on exceptions that can crash your program unpredictably, F# encourages explicit error handling through types. This makes your code more predictable, testable, and maintainable. By combining discriminated unions, the Result type, and pattern matching, you can model errors as values and handle them systematically.

In this lesson, you'll discover how F# transforms error handling from an afterthought into a first-class design concern, giving you powerful tools to manage effects and maintain program correctness.

Core Concepts πŸ’‘

The Problem with Exceptions ⚠️

Traditional exception-based error handling has several drawbacks:

  • Hidden control flow: Exceptions aren't visible in function signatures
  • Performance overhead: Throwing exceptions is expensive
  • Difficult to compose: Try-catch blocks don't chain well
  • Type system bypass: Exceptions circumvent type safety

Consider this C#-style approach:

// ❌ Exception-based (not idiomatic F#)
let divide x y =
    if y = 0 then
        raise (System.DivideByZeroException())
    else
        x / y

The function signature int -> int -> int doesn't reveal that it can fail! Callers might forget to handle the error.

Errors as Values βœ…

Functional programming treats errors as data, not exceptional circumstances. We model success and failure explicitly using types:

// βœ… Functional approach
let divide x y =
    if y = 0 then
        None  // Failure case
    else
        Some (x / y)  // Success case

Now the signature int -> int -> int option clearly communicates potential failure!

The Option Type πŸ“¦

The Option type represents computations that might not return a value:

type Option<'T> =
    | Some of 'T
    | None

Use Option when: A value might be absent, but you don't need to explain why.

ScenarioReturn Value
Finding item in listSome item or None
Parsing valid inputSome value or None
Safe divisionSome result or None

The Result Type 🎯

The Result type goes further by including error information:

type Result<'T, 'TError> =
    | Ok of 'T
    | Error of 'TError

Use Result when: You need to communicate why something failed.

type DivisionError =
    | DivideByZero
    | Overflow

let safeDivide x y =
    if y = 0 then
        Error DivideByZero
    elif x = System.Int32.MinValue && y = -1 then
        Error Overflow
    else
        Ok (x / y)

Now the signature int -> int -> Result<int, DivisionError> is completely explicit!

Pattern Matching for Error Handling πŸ”

Pattern matching makes working with Result and Option elegant:

let handleDivision x y =
    match safeDivide x y with
    | Ok result -> 
        printfn "Result: %d" result
    | Error DivideByZero -> 
        printfn "Cannot divide by zero!"
    | Error Overflow -> 
        printfn "Calculation overflow!"

The compiler guarantees you handle all casesβ€”no forgotten error paths!

Composing Results πŸ”—

The real power emerges when chaining operations. The Result module provides functions for this:

let validateAge age =
    if age < 0 then Error "Age cannot be negative"
    elif age > 150 then Error "Age unrealistic"
    else Ok age

let validateName name =
    if System.String.IsNullOrWhiteSpace(name) then
        Error "Name cannot be empty"
    else
        Ok name

// Combine validations
let createPerson name age =
    match validateName name, validateAge age with
    | Ok validName, Ok validAge -> 
        Ok { Name = validName; Age = validAge }
    | Error nameErr, Error ageErr -> 
        Error (sprintf "%s; %s" nameErr ageErr)
    | Error err, _ | _, Error err -> 
        Error err

Railway-Oriented Programming πŸš‚

Scott Wlaschin coined this term to describe error handling as two tracks:

SUCCESS TRACK (Ok values)
════════════════════════════════════════
  Input β†’ Function1 β†’ Function2 β†’ Output
════════════════════════════════════════

FAILURE TRACK (Error values)
────────────────────────────────────────
         ↓ Error    ↓ Error      ↓ Error
────────────────────────────────────────

Once computation hits an error, it stays on the "error track" until explicitly handled.

Result.bind enables this pattern:

let (>>=) result func = Result.bind func result

// Chain operations
let processUser input =
    input
    |> validateName
    >>= validateAge
    >>= saveToDatabase
    >>= sendWelcomeEmail

Each function only runs if the previous succeeded. First error short-circuits the chain!

Custom Error Types 🎨

Design discriminated unions that describe your domain errors:

type ValidationError =
    | EmptyField of fieldName: string
    | InvalidFormat of fieldName: string * expected: string
    | OutOfRange of fieldName: string * min: int * max: int

type DatabaseError =
    | ConnectionFailed of reason: string
    | RecordNotFound of id: int
    | DuplicateKey of key: string

type ApplicationError =
    | Validation of ValidationError
    | Database of DatabaseError
    | Network of System.Exception

This creates a hierarchy of errors that's type-safe and self-documenting!

The map Function πŸ—ΊοΈ

Result.map transforms success values without unwrapping:

let result = Ok 5
let doubled = result |> Result.map (fun x -> x * 2)
// doubled = Ok 10

let error = Error "Failed"
let doubled' = error |> Result.map (fun x -> x * 2)
// doubled' = Error "Failed" (unchanged)

Think of it as "if Ok, transform the value; if Error, pass through unchanged."

The bind Function πŸ”—

Result.bind chains functions that themselves return Result:

let parseNumber str =
    match System.Int32.TryParse(str) with
    | true, num -> Ok num
    | false, _ -> Error "Not a number"

let doubleIfEven num =
    if num % 2 = 0 then Ok (num * 2)
    else Error "Not even"

// Chaining with bind
let processInput input =
    parseNumber input
    |> Result.bind doubleIfEven

// "42" β†’ Ok 42 β†’ Ok 84
// "43" β†’ Ok 43 β†’ Error "Not even"
// "abc" β†’ Error "Not a number" β†’ Error "Not a number"

Detailed Examples πŸ”¬

Example 1: User Registration System πŸ‘€

Let's build a complete registration system with comprehensive error handling:

type RegistrationError =
    | InvalidEmail of string
    | WeakPassword of string
    | UsernameTaken of string
    | DatabaseError of string

type User = {
    Email: string
    Username: string
    PasswordHash: string
}

let validateEmail email =
    if email.Contains("@") && email.Contains(".") then
        Ok email
    else
        Error (InvalidEmail "Email must contain @ and domain")

let validatePassword password =
    if password.Length >= 8 then
        Ok password
    else
        Error (WeakPassword "Password must be 8+ characters")

let checkUsernameAvailable username =
    // Simulate database check
    if username = "admin" then
        Error (UsernameTaken "Username already exists")
    else
        Ok username

let hashPassword password =
    Ok (sprintf "hashed_%s" password)  // Simplified

let saveUser email username passwordHash =
    // Simulate database operation
    try
        Ok { Email = email; Username = username; PasswordHash = passwordHash }
    with ex ->
        Error (DatabaseError ex.Message)

// Compose everything
let registerUser email username password =
    result {
        let! validEmail = validateEmail email
        let! validPassword = validatePassword password
        let! availableUsername = checkUsernameAvailable username
        let! hashedPassword = hashPassword validPassword
        let! user = saveUser validEmail availableUsername hashedPassword
        return user
    }

// Usage with pattern matching
match registerUser "test@example.com" "admin" "pass123" with
| Ok user -> 
    printfn "Registered: %s" user.Email
| Error (InvalidEmail msg) -> 
    printfn "Email error: %s" msg
| Error (WeakPassword msg) -> 
    printfn "Password error: %s" msg
| Error (UsernameTaken msg) -> 
    printfn "Username error: %s" msg
| Error (DatabaseError msg) -> 
    printfn "System error: %s" msg

Key takeaways:

  • Each validation is a separate, testable function
  • Computation expression (result { }) sequences operations cleanly
  • Pattern matching ensures all error types are handled
  • Type system prevents forgetting error cases

Example 2: File Processing Pipeline πŸ“

Handling I/O operations that can fail at multiple stages:

type FileError =
    | FileNotFound of path: string
    | AccessDenied of path: string
    | InvalidFormat of reason: string
    | ProcessingError of reason: string

let readFile path =
    try
        let content = System.IO.File.ReadAllText(path)
        Ok content
    with
    | :? System.IO.FileNotFoundException ->
        Error (FileNotFound path)
    | :? System.UnauthorizedAccessException ->
        Error (AccessDenied path)
    | ex ->
        Error (ProcessingError ex.Message)

let parseJson content =
    try
        // Simplified JSON parsing
        if content.StartsWith("{") then
            Ok content
        else
            Error (InvalidFormat "Not valid JSON")
    with ex ->
        Error (ProcessingError ex.Message)

let extractField fieldName json =
    if json.Contains(fieldName) then
        Ok (sprintf "value_of_%s" fieldName)
    else
        Error (InvalidFormat (sprintf "Missing field: %s" fieldName))

let validateValue value =
    if value.Length > 0 then
        Ok value
    else
        Error (InvalidFormat "Empty value")

// Complete pipeline
let processConfigFile path fieldName =
    readFile path
    |> Result.bind parseJson
    |> Result.bind (extractField fieldName)
    |> Result.bind validateValue
    |> Result.map (fun value -> 
        printfn "Processed: %s" value
        value)

// Alternative: using computation expression
let processConfigFile' path fieldName =
    result {
        let! content = readFile path
        let! json = parseJson content
        let! fieldValue = extractField fieldName json
        let! validated = validateValue fieldValue
        printfn "Processed: %s" validated
        return validated
    }

Railway-oriented visualization:

readFile β†’ parseJson β†’ extractField β†’ validateValue β†’ Output
   β”‚          β”‚             β”‚              β”‚
   ↓ Error    ↓ Error       ↓ Error        ↓ Error
   └──────────┴─────────────┴──────────────┴──→ Error Result

Example 3: Async Error Handling ⚑

Combining Result with asynchronous operations:

type ApiError =
    | NetworkError of string
    | InvalidResponse of int
    | Timeout
    | ParseError of string

let fetchData url = async {
    try
        use client = new System.Net.Http.HttpClient()
        client.Timeout <- System.TimeSpan.FromSeconds(5.0)
        let! response = client.GetAsync(url) |> Async.AwaitTask
        
        if response.IsSuccessStatusCode then
            let! content = response.Content.ReadAsStringAsync() |> Async.AwaitTask
            return Ok content
        else
            return Error (InvalidResponse (int response.StatusCode))
    with
    | :? System.Threading.Tasks.TaskCanceledException ->
        return Error Timeout
    | ex ->
        return Error (NetworkError ex.Message)
}

let parseResponse content =
    try
        // Simulate parsing
        if content.Length > 0 then
            Ok { Data = content }
        else
            Error (ParseError "Empty response")
    with ex ->
        Error (ParseError ex.Message)

// Async + Result composition
let fetchAndParse url = async {
    let! fetchResult = fetchData url
    return
        fetchResult
        |> Result.bind parseResponse
}

// Usage
let processApi url = async {
    let! result = fetchAndParse url
    match result with
    | Ok data ->
        printfn "Success: %s" data.Data
    | Error (NetworkError msg) ->
        printfn "Network failed: %s" msg
    | Error (InvalidResponse code) ->
        printfn "HTTP %d error" code
    | Error Timeout ->
        printfn "Request timed out"
    | Error (ParseError msg) ->
        printfn "Parse failed: %s" msg
}

πŸ’‘ Pro tip: The Async<Result<'T, 'E>> pattern is so common that libraries like FsToolkit.ErrorHandling provide computation expressions specifically for it!

Example 4: Effect Management with Monads 🎭

Managing side effects explicitly:

type Effect<'T> =
    | Pure of 'T
    | Effect of (unit -> 'T)

module Effect =
    let run effect =
        match effect with
        | Pure value -> value
        | Effect func -> func()
    
    let map f effect =
        match effect with
        | Pure value -> Pure (f value)
        | Effect func -> Effect (fun () -> f (func()))
    
    let bind f effect =
        match effect with
        | Pure value -> f value
        | Effect func -> Effect (fun () -> run (f (func())))

// Wrapping side effects
let readFromConsole = Effect (fun () ->
    printf "Enter name: "
    System.Console.ReadLine()
)

let writeToConsole message = Effect (fun () ->
    printfn "%s" message
)

let greetUser =
    readFromConsole
    |> Effect.map (sprintf "Hello, %s!")
    |> Effect.bind writeToConsole

// Effects don't run until explicitly executed
Effect.run greetUser

This pattern separates description from execution, making code testable and composable!

Common Mistakes ⚠️

Mistake 1: Using Exceptions for Control Flow

❌ Wrong:

let processUser id =
    try
        let user = findUser id  // throws if not found
        updateUser user
        Ok user
    with
    | :? UserNotFoundException -> Error "Not found"

βœ… Right:

let processUser id =
    match findUser id with  // returns Result
    | Ok user ->
        updateUser user
        Ok user
    | Error _ as err -> err

Why: Exceptions are expensive and bypass type safety. Result types make errors explicit.

Mistake 2: Forgetting to Handle Error Cases

❌ Wrong:

let getUserAge id =
    let result = findUser id
    match result with
    | Ok user -> user.Age
    // Missing Error case! Compiler will catch this.

βœ… Right:

let getUserAge id =
    match findUser id with
    | Ok user -> Some user.Age
    | Error _ -> None

Why: Exhaustive pattern matching is F#'s superpowerβ€”use it!

Mistake 3: Nested Match Statements (Pyramid of Doom)

❌ Wrong:

let processOrder orderId =
    match findOrder orderId with
    | Ok order ->
        match validateOrder order with
        | Ok validOrder ->
            match processPayment validOrder with
            | Ok payment -> Ok payment
            | Error err -> Error err
        | Error err -> Error err
    | Error err -> Error err

βœ… Right:

let processOrder orderId =
    result {
        let! order = findOrder orderId
        let! validOrder = validateOrder order
        let! payment = processPayment validOrder
        return payment
    }

Why: Computation expressions flatten the structure, improving readability.

Mistake 4: Mixing Option and Result Inappropriately

❌ Wrong:

let divide x y =
    if y = 0 then None
    else Some (x / y)
// Caller doesn't know WHY it failed

βœ… Right:

let divide x y =
    if y = 0 then Error "Division by zero"
    else Ok (x / y)
// Clear error message

Rule of thumb: Use Option for "value present or not", Result for "success or specific failure".

Mistake 5: Not Composing Functions

❌ Wrong:

let processData input =
    let step1 = validate input
    if Result.isOk step1 then
        let value1 = Result.get step1
        let step2 = transform value1
        if Result.isOk step2 then
            let value2 = Result.get step2
            save value2
        else
            step2
    else
        step1

βœ… Right:

let processData input =
    input
    |> validate
    |> Result.bind transform
    |> Result.bind save

Why: Pipelines with bind are cleaner and more maintainable.

Key Takeaways 🎯

πŸ“‹ Quick Reference Card

Concept Usage When to Use
Option<'T> Some value or None Value might be absent, no need for error details
Result<'T,'E> Ok value or Error info Need to communicate why operation failed
Result.map Transform success values Converting types without risk of failure
Result.bind Chain fallible operations Next operation can also fail
result { } Computation expression Multiple sequential operations with let!
Pattern matching Exhaustive case handling Alwaysβ€”compiler enforces completeness

Essential Patterns 🧠

Mnemonic: "MERGE"

  • Map transforms values
  • Errors as values, not exceptions
  • Result for detailed failures
  • Guard with pattern matching
  • Express effects explicitly

The Railway-Oriented Programming Mindset πŸš‚

Think of your program as two parallel tracks:

  1. Happy path (Ok track): Everything succeeds
  2. Error path (Error track): Something failed

Functions like bind automatically route values to the correct track. Once on the error track, computation stays there until you explicitly handle it.

Composition Over Imperative Code ⚑

Prefer:

input |> validate |> Result.bind process |> Result.map save

Over:

let validated = validate input
if validated succeeds then
    let processed = process validated
    if processed succeeds then
        save processed

Functional composition is more concise and maintainable!

πŸ“š Further Study

Deepen your understanding with these resources:

  1. Railway Oriented Programming - Scott Wlaschin's excellent article: https://fsharpforfunandprofit.com/rop/
  2. F# for Fun and Profit - Error Handling: https://fsharpforfunandprofit.com/posts/recipe-part2/
  3. FsToolkit.ErrorHandling - Library for advanced error handling patterns: https://github.com/demystifyfp/FsToolkit.ErrorHandling

🧠 Remember: In F#, errors aren't exceptionalβ€”they're expected! Model them explicitly with types, handle them systematically with pattern matching, and compose operations cleanly with Result.bind. Your future self (and your teammates) will thank you for code that's impossible to misuse.

Now you're ready to write robust, type-safe error handling that makes impossible states impossible! πŸš€