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.
| Scenario | Return Value |
|---|---|
| Finding item in list | Some item or None |
| Parsing valid input | Some value or None |
| Safe division | Some 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:
- Happy path (Ok track): Everything succeeds
- 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:
- Railway Oriented Programming - Scott Wlaschin's excellent article: https://fsharpforfunandprofit.com/rop/
- F# for Fun and Profit - Error Handling: https://fsharpforfunandprofit.com/posts/recipe-part2/
- 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! π