Computation Expressions
Use async, result, and custom computation expressions to manage effects with clean, sequential syntax.
ACTIVE PRACTICE Β· 15 practice questions
Make this lesson stick.
Test what you can recall and learn from the feedback. Come back to the lesson whenever you need an explanation.
Sign in to practice β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!