Lets See What We Can do! with F# Computation Expressions • Andrew Browne • YOW! 2015

Andrew Browne demystifies F# Computation Expressions, showing how this powerful feature is a simple syntactic transformation. He demonstrates building custom expressions for handling optional values (maybe), asynchronous sequences, and even creating a Domain-Specific Language (DSL) with Free Monads for robust testing.

5 min read
Lets See What We Can do! with F# Computation Expressions • Andrew Browne • YOW! 2015
source: https://www.youtube.com/watch?v=NFofVoR8EZo

F# computation expressions are often perceived as an advanced feature, but they are fundamentally a straightforward mechanism for syntactic transformation. This allows developers to create powerful, domain-specific control flow structures that look like built-in language features.

Understanding the Core Concept

Computation expressions provide syntactic sugar over patterns of composing functions, particularly those that are monadic in nature. The F# compiler takes code written inside a special block and translates it into a series of method calls on a “builder” object. This happens before type-checking, meaning the only requirement is that the transformed code compiles.

Two familiar, built-in examples are seq for lazy sequences and async for asynchronous workflows.

  • In a seq expression, yield produces a single item, while yield! (yield bang) flattens a subsequence into the main sequence.
  • In an async block, let! (let bang) is used to execute an asynchronous operation and bind its result to a variable, making the code appear sequential and imperative while being non-blocking under the hood. The ! is the key indicator that a computation expression is at work.

Building a Custom Expression: The maybe Monad

A classic example is creating a maybe expression to simplify working with F#’s Option type, which is either Some value or None. This expression allows chaining operations that can fail (return None) without explicit pattern matching at each step.

Consider this code inside a maybe block:

maybe {
    let! name = lookupName "id"
    let! age = lookupAge name
    return sprintf "%s is %d" name age
}

If either lookupName or lookupAge returns None, the entire block short-circuits and evaluates to None.

The compiler transforms this into a series of nested calls to a Bind method on a maybeBuilder object. The final expression is wrapped in a Return call.

maybe.Bind(lookupName "id", fun name ->
    maybe.Bind(lookupAge name, fun age ->
        maybe.Return(sprintf "%s is %d" name age)
    )
)

The maybeBuilder is a simple object with methods that define the behavior of the expression:

  • Return(x): Wraps a value, typically as Some x.
  • Bind(opt, f): Takes an option opt and a function f (the continuation). If opt is None, it returns None. If it’s Some v, it applies f to v.

This pattern is directly analogous to Haskell’s do notation or Scala’s for expressions.

The Full Range of Compiler Transformations

The F# compiler recognizes a wide range of keywords within a computation expression and translates them into corresponding methods on the builder.

Keyword(s) Transformed Method Call Purpose
let! p = e builder.Bind(e, fun p -> ...) Binds the result of a wrapped computation.
do! e builder.Bind(e, fun () -> ...) Executes a wrapped computation for its side effect.
return e builder.Return(e) Wraps a final value in the computation.
return! e builder.ReturnFrom(e) Returns from an existing wrapped computation.
yield e builder.Yield(e) Yields a value in a sequence-like computation.
yield! e builder.YieldFrom(e) Yields all values from a subsequence.
for x in e do ... builder.For(e, fun x -> ...) Loops over a sequence.
while e do ... builder.While(fun () -> e, ...) A standard while loop.
use! p = e builder.Bind(e, fun p -> ...) Bind for a value that needs disposal (IDisposable).
if ... else ... if ... then builder.Return(...) else ... Conditional logic.
(if without else) builder.Zero() Provides a default value if an if has no else.

Practical Use Case 1: Asynchronous Sequences

A powerful application is handling paged, asynchronous data sources. The goal is to fetch data page by page without blocking and without loading the entire dataset into memory, preserving both asynchrony and laziness.

A custom asyncSeq computation expression can be built around a data structure that represents an async stream. The builder for this expression allows you to write code that looks simple and sequential but correctly composes the asynchronous and lazy nature of the operation.

let getAllResults (getPage: int -> Async<seq<'T>>) =
    asyncSeq {
        let mutable pageNum = 0
        let mutable isDone = false
        while not isDone do
            let! currentPage = getPage pageNum
            if not (Seq.isEmpty currentPage) then
                yield! currentPage
                pageNum <- pageNum + 1
            else
                isDone <- true
    }

This code fetches one page at a time (let!), yields its contents (yield!), and only proceeds to the next page when the consumer requests more data, all without blocking. Higher-order functions like filter or map can then be built on top of this asyncSeq expression, creating a fully composable library for handling async streams.

Practical Use Case 2: Building a DSL with Free Monads

Computation expressions are an excellent tool for creating Domain-Specific Languages (DSLs). By defining a computation expression, you can write programs that build up a pure data structure (an Abstract Syntax Tree) representing a sequence of operations. This separates the specification of the program from its execution.

This is the core idea behind the Free Monad pattern. For instance, to test a key-value store, you can define a keyValue language:

// This builds a data structure, it doesn't execute anything.
let myProgram =
    keyValue {
        let! maybeValue = readValue "mykey"
        match maybeValue with
        | Some (version, value) ->
            let newValue = value + 1
            do! writeValue "mykey" (version + 1) newValue
        | None ->
            do! writeValue "mykey" 0 1
        return "Done"
    }

This program can then be run using different interpreters:

  1. A Test Interpreter: Executes the program against a simple in-memory Map, allowing for fast, deterministic unit tests without any real I/O.
  2. A Production Interpreter: Translates the program steps into actual asynchronous calls to a real database (e.g., EventStore, Redis).
  3. A Concurrency Test Interpreter: Takes multiple programs and interleaves their execution steps to test for race conditions and other concurrency issues, which can be combined with property-based testing for powerful validation.

This approach provides immense power and flexibility, enabling robust testing of complex logic by abstracting away the underlying implementation details.

Share post

Stay In The Loop! Subscribe to Our Newsletter.

Get updates straight to your inbox. No spam, just useful content.

Recommended for You

Knowledge is Eventually Consistent // Devin Stein // MLOps Podcast #335

Knowledge is Eventually Consistent // Devin Stein // MLOps Podcast #335

Devin Stein, CEO of Dosu, discusses a new paradigm for knowledge management where an AI agent learns from code, conversations, and tickets to create an 'eventually consistent' knowledge base. The conversation explores the lifecycle of facts, the challenges of agent interaction, and the future of documentation in a world of collaborating AI agents.

Why ChatGPT will be the next big growth channel (and how to capitalize on it) | Brian Balfour

Why ChatGPT will be the next big growth channel (and how to capitalize on it) | Brian Balfour

Brian Balfour, founder of Reforge, explains the predictable four-step cycle of technology platforms, from open to closed, and argues that ChatGPT is about to enter the "open" phase, creating a massive, time-sensitive distribution opportunity that startups and incumbents alike cannot afford to ignore.