Computation Expressions
Use async, result, and custom computation expressions to manage effects with clean, sequential syntax.
Computation Expressions in F#
Master computation expressions with free flashcards and spaced repetition practice. This lesson covers builder syntax, custom workflows, and built-in computation expressionsβessential concepts for writing expressive, composable F# code. Computation expressions provide a powerful abstraction mechanism that lets you write asynchronous, monadic, and domain-specific code in a natural, imperative-looking style.
Welcome π»
Welcome to one of F#'s most distinctive and powerful features! Computation expressions (sometimes called "workflows") allow you to write complex computational patternsβlike asynchronous operations, option handling, or query buildingβusing familiar-looking syntax that hides the underlying complexity. Think of them as F#'s answer to Haskell's do-notation or C#'s LINQ query syntax, but more flexible and extensible.
By the end of this lesson, you'll understand:
- What computation expressions are and why they matter
- How to use built-in computation expressions like
async,seq, andoption - The builder pattern that powers computation expressions
- How to read and write custom computation expressions
- Common pitfalls and best practices
Core Concepts π§
What Are Computation Expressions?
A computation expression is a special syntax that wraps computations in a particular context. The general form looks like this:
builderName {
// computation code here
let! x = someValue
return x + 1
}
The builderName refers to a builder object that defines how to interpret the code inside the braces. The builder contains methods like Bind, Return, ReturnFrom, Zero, and others that transform your imperative-looking code into function calls.
π‘ Think of it this way: Computation expressions are like a mini-language within F# where the builder acts as an interpreter, translating your code into operations specific to a computational context.
The Builder Pattern ποΈ
βββββββββββββββββββββββββββββββββββββββββββββββ
β COMPUTATION EXPRESSION ANATOMY β
βββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β async { β Builder name β
β let! data = ... β Bind operation β
β let x = ... β Regular let β
β return x β Return operation β
β } β
β β
β βββββββββββββββββββββββββββββββββββ β
β β Builder Object Methods: β β
β β β’ Bind(x, f) β let! support β β
β β β’ Return(x) β return β β
β β β’ ReturnFrom(x) β return! β β
β β β’ Zero() β empty expr β β
β β β’ Combine(a, b) β sequencing β β
β β β’ Delay(f) β lazy eval β β
β βββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββ
Each builder method corresponds to a specific operation:
| Method | Syntax It Enables | Purpose |
|---|---|---|
| Bind(x, f) | let! value = expr |
Unwrap a wrapped value and pass it to continuation |
| Return(x) | return x |
Wrap a plain value into the computation context |
| ReturnFrom(x) | return! x |
Return an already-wrapped value |
| Zero() | Empty branches | Provide default value when no return |
| Combine(a, b) | Multiple expressions | Sequence two computations |
| Delay(f) | Entire body | Defer execution until needed |
Built-in Computation Expressions π¦
1. async - Asynchronous Workflows β‘
The async computation expression is used for asynchronous programming:
let downloadDataAsync (url: string) = async {
let client = new System.Net.Http.HttpClient()
let! response = client.GetStringAsync(url) |> Async.AwaitTask
return response.Length
}
The let! keyword unwraps the async result, allowing you to write sequential-looking code that actually executes asynchronously.
Key operations:
let!- Bind an async valuereturn- Wrap a value in asyncreturn!- Return an already-async valuedo!- Perform async operation without binding result
2. seq - Lazy Sequences π
The seq expression creates lazy sequences (IEnumerable):
let numbers = seq {
for i in 1..10 do
printfn "Generating %d" i
yield i * i
}
The sequence is evaluated lazilyβvalues are computed only when requested.
Key operations:
yield- Produce a single valueyield!- Produce all values from another sequence
3. option - Optional Value Handling π―
While not built into the language, the option computation expression is commonly defined:
type OptionBuilder() =
member _.Bind(x, f) = Option.bind f x
member _.Return(x) = Some x
member _.ReturnFrom(x) = x
let option = OptionBuilder()
let divide x y =
if y = 0 then None else Some(x / y)
let computation = option {
let! a = divide 10 2 // a = 5
let! b = divide a 5 // b = 1
return b + 1 // returns Some 2
}
π§ Mnemonic: BRAD - The four core builder methods are Bind, Return, ReturnFrom (return!), and Delay.
Understanding let! vs let π
This is a critical distinction:
async {
// let! unwraps the Async<string> to get string
let! data = downloadAsync "http://example.com"
let length = data.Length // data is string here
// let keeps it wrapped
let wrappedData = downloadAsync "http://example.com"
// wrappedData is Async<string>, not string!
}
| Syntax | What It Does | Result Type |
|---|---|---|
let x = expr |
Regular binding | Whatever expr returns |
let! x = expr |
Unwrap computation | Unwrapped value |
return x |
Wrap and return | Wrapped value |
return! x |
Return already wrapped | Wrapped value (no double wrap) |
How Computation Expressions Desugar π
Understanding the transformation helps demystify computation expressions. Here's what the compiler does:
// This code:
async {
let! x = async1
let! y = async2
return x + y
}
// Becomes roughly:
async.Bind(async1, fun x ->
async.Bind(async2, fun y ->
async.Return(x + y)))
Each let! becomes a Bind call, and return becomes a Return call. The nested structure creates the sequential behavior.
Control Flow in Computation Expressions π
let exampleWithControlFlow = async {
let! result = someAsyncOperation()
if result > 0 then
printfn "Positive"
return result
else
printfn "Non-positive"
return 0
}
Control flow (if/else, match, loops) works naturally inside computation expressions. The builder's Zero() method handles branches that don't return values.
π‘ Pro tip: If you get compilation errors about missing Zero() or Combine(), it means the builder doesn't support the control flow structure you're trying to use.
Examples with Detailed Explanations π
Example 1: Async HTTP Requests
open System.Net.Http
let fetchMultipleUrls urls = async {
let client = new HttpClient()
let! results =
urls
|> List.map (fun url -> async {
let! response = client.GetStringAsync(url) |> Async.AwaitTask
return (url, response.Length)
})
|> Async.Sequential
return results |> Array.toList
}
// Usage
let urls = ["http://example.com"; "http://example.org"]
let results = fetchMultipleUrls urls |> Async.RunSynchronously
Explanation:
- The outer
asyncblock creates an asynchronous workflow - We map over URLs, creating an async computation for each
Async.SequentialconvertsAsync<'T> listtoAsync<'T[]>let!unwraps the array of results- The entire function returns
Async<(string * int) list>
π§ Try this: Change Async.Sequential to Async.Parallel to run requests concurrently!
Example 2: Sequence Expressions with Filtering
let primeSequence = seq {
yield 2
for n in 3..2..1000 do // Only odd numbers
let isPrime =
seq { 2..int(sqrt(float n)) }
|> Seq.forall (fun d -> n % d <> 0)
if isPrime then
yield n
}
// Get first 10 primes
let firstTenPrimes = primeSequence |> Seq.take 10 |> Seq.toList
// [2; 3; 5; 7; 11; 13; 17; 19; 23; 29]
Explanation:
seqcreates a lazy sequenceβvalues computed on demandyieldproduces individual values- The primality check is evaluated only when sequence is consumed
- This is memory-efficient: doesn't generate all values upfront
π€ Did you know? Sequence expressions can be infinite! seq { while true do yield 1 } is perfectly valid (though you'll want to use Seq.take to consume it).
Example 3: Custom Result Builder
type Result<'T, 'TError> =
| Ok of 'T
| Error of 'TError
type ResultBuilder() =
member _.Bind(x, f) =
match x with
| Ok value -> f value
| Error e -> Error e
member _.Return(x) = Ok x
member _.ReturnFrom(x) = x
let result = ResultBuilder()
// Example usage
let divide x y =
if y = 0 then Error "Division by zero"
else Ok (x / y)
let safeMath = result {
let! a = divide 10 2 // a = 5
let! b = divide a 5 // b = 1
let! c = divide b 0 // Error stops here!
return c + 1 // Never executed
}
// safeMath = Error "Division by zero"
Explanation:
- We define a custom
Resulttype (similar toOptionbut with error info) - The
ResultBuilderimplementsBind,Return, andReturnFrom Bindchecks if previous operation succeeded before continuing- On first
Error, the entire computation short-circuits - This eliminates nested
matchstatements for error handling
Without computation expression (for comparison):
match divide 10 2 with
| Error e -> Error e
| Ok a ->
match divide a 5 with
| Error e -> Error e
| Ok b ->
match divide b 0 with
| Error e -> Error e
| Ok c -> Ok (c + 1)
Much less readable! π―
Example 4: List Builder with Validation
type ListBuilder() =
member _.Bind(list, f) = List.collect f list
member _.Return(x) = [x]
member _.Zero() = []
member _.Yield(x) = [x]
member _.For(seq, f) = Seq.toList seq |> List.collect f
let listBuilder = ListBuilder()
let cartesianProduct = listBuilder {
for x in [1; 2; 3] do
for y in ["a"; "b"] do
yield (x, y)
}
// Result: [(1,"a"); (1,"b"); (2,"a"); (2,"b"); (3,"a"); (3,"b")]
Explanation:
Formethod enablesfor...dosyntax inside the builder- Each iteration produces a list
List.collectflattens all the nested lists- This creates a Cartesian product of the two sequences
- The
yieldkeyword produces values into the resulting list
π Real-world analogy: This is like a nested loop in imperative languages, but it produces a list instead of side effects.
Common Mistakes β οΈ
Mistake 1: Confusing let and let!
β Wrong:
async {
let data = downloadAsync "http://example.com" // data is Async<string>
return data.Length // ERROR: Async<string> doesn't have Length!
}
β Correct:
async {
let! data = downloadAsync "http://example.com" // data is string
return data.Length // Works!
}
Why it matters: let doesn't unwrap the computation context. Always use let! when you need the unwrapped value.
Mistake 2: Forgetting return!
β Wrong:
let compute = async {
let! x = async { return 5 }
if x > 0 then
return async { return x * 2 } // Returns Async<Async<int>>!
}
β Correct:
let compute = async {
let! x = async { return 5 }
if x > 0 then
return! async { return x * 2 } // Use return! for wrapped values
}
Rule of thumb: Use return! when the value is already wrapped in the computation type.
Mistake 3: Missing Zero() Implementation
β Wrong:
type MyBuilder() =
member _.Bind(x, f) = ...
member _.Return(x) = ...
// No Zero() method!
let myBuilder = MyBuilder()
let example = myBuilder {
let! x = something
if x > 0 then
return x // ERROR: else branch has no value, needs Zero()!
}
β Correct:
type MyBuilder() =
member _.Bind(x, f) = ...
member _.Return(x) = ...
member _.Zero() = defaultValue // Provide default for empty branches
Mistake 4: Using do! Instead of let!
β Wrong:
async {
do! downloadAsync "http://example.com" // Ignores the result
return data // ERROR: 'data' doesn't exist!
}
β Correct:
async {
let! data = downloadAsync "http://example.com"
return data // Works!
}
When to use do!: Only when you don't need the result, like: do! Async.Sleep 1000
Mistake 5: Forgetting Async.AwaitTask
β Wrong:
open System.Net.Http
let fetch url = async {
let client = new HttpClient()
let! result = client.GetStringAsync(url) // ERROR: Task<string> β Async<string>
return result
}
β Correct:
let fetch url = async {
let client = new HttpClient()
let! result = client.GetStringAsync(url) |> Async.AwaitTask
return result
}
Why: .NET's Task<'T> and F#'s Async<'T> are different types. Use Async.AwaitTask to convert.
Key Takeaways π―
Computation expressions provide syntactic sugar for sequential-looking code in various computational contexts (async, option, result, etc.)
Builders are objects with specific methods (
Bind,Return, etc.) that define how the computation expression is interpretedlet!unwraps values from the computational context, whileletkeeps them wrappedreturnwraps a plain value, whilereturn!returns an already-wrapped valueCommon built-in expressions:
async(asynchronous workflows),seq(lazy sequences)Custom builders let you create domain-specific syntax for your own computational patterns
The bang (!) suffix on keywords (
let!,do!,return!,yield!) means "work with the wrapped value"
π Quick Reference Card
| Syntax | Meaning | Builder Method |
let! x = expr |
Unwrap and bind | Bind(expr, fun x -> ...) |
return x |
Wrap value | Return(x) |
return! x |
Return wrapped | ReturnFrom(x) |
do! expr |
Execute, ignore result | Bind(expr, fun () -> ...) |
yield x |
Produce value (seq) | Yield(x) |
yield! seq |
Produce all values | YieldFrom(seq) |
Common Patterns:
- π Sequential async:
let! x = async1; let! y = async2 - β‘ Parallel async:
Async.Parallel [async1; async2] - π― Error handling: Use Result builder to avoid nested matches
- π Lazy sequences: Use seq with yield for on-demand computation
π Further Study
- F# Language Reference - Computation Expressions: https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/computation-expressions
- Understanding Computation Expressions: https://fsharpforfunandprofit.com/posts/computation-expressions-intro/
- Async Programming in F#: https://learn.microsoft.com/en-us/dotnet/fsharp/tutorials/async
π‘ Remember: Computation expressions are about making complex patterns simple. When you find yourself writing repetitive matching or nesting, consider if a computation expression could help!