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

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, and option
  • 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 value
  • return - Wrap a value in async
  • return! - Return an already-async value
  • do! - 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 value
  • yield! - 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:

  1. The outer async block creates an asynchronous workflow
  2. We map over URLs, creating an async computation for each
  3. Async.Sequential converts Async<'T> list to Async<'T[]>
  4. let! unwraps the array of results
  5. 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:

  1. seq creates a lazy sequenceβ€”values computed on demand
  2. yield produces individual values
  3. The primality check is evaluated only when sequence is consumed
  4. 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:

  1. We define a custom Result type (similar to Option but with error info)
  2. The ResultBuilder implements Bind, Return, and ReturnFrom
  3. Bind checks if previous operation succeeded before continuing
  4. On first Error, the entire computation short-circuits
  5. This eliminates nested match statements 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:

  1. For method enables for...do syntax inside the builder
  2. Each iteration produces a list
  3. List.collect flattens all the nested lists
  4. This creates a Cartesian product of the two sequences
  5. The yield keyword 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 🎯

  1. Computation expressions provide syntactic sugar for sequential-looking code in various computational contexts (async, option, result, etc.)

  2. Builders are objects with specific methods (Bind, Return, etc.) that define how the computation expression is interpreted

  3. let! unwraps values from the computational context, while let keeps them wrapped

  4. return wraps a plain value, while return! returns an already-wrapped value

  5. Common built-in expressions: async (asynchronous workflows), seq (lazy sequences)

  6. Custom builders let you create domain-specific syntax for your own computational patterns

  7. 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

  1. F# Language Reference - Computation Expressions: https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/computation-expressions
  2. Understanding Computation Expressions: https://fsharpforfunandprofit.com/posts/computation-expressions-intro/
  3. 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!

Practice Questions

Test your understanding with these questions:

Q1: Complete the async binding: ```fsharp async { {{1}} data = downloadAsync "http://example.com" return data.Length } ```
A: let!
Q2: What does this code produce? ```fsharp let result = seq { for i in 1..3 do yield i * 2 } result |> Seq.toList ``` A. [1; 2; 3] B. [2; 4; 6] C. [2; 2; 2] D. Error: invalid syntax E. [1; 4; 9]
A: B
Q3: Fill in the cloze deletion: ```fsharp type ResultBuilder() = member _.{{1}}(x, f) = match x with | Ok v -> f v | Error e -> Error e member _.{{2}}(x) = Ok x let result = ResultBuilder() ```
A: ["Bind","Return"]
Q4: Complete the return statement: ```fsharp async { let! x = async { return 5 } {{1}} async { return x * 2 } } ```
A: return!
Q5: What is the output? ```fsharp let computation = seq { yield 1 yield! [2; 3; 4] yield 5 } computation |> Seq.toList ``` A. [1; [2; 3; 4]; 5] B. [1; 2; 3; 4; 5] C. [[1]; [2; 3; 4]; [5]] D. Error: type mismatch E. [1; 5]
A: B