Skip to content
AgileLogics
7 min read

Writing Your Own Computation Expression

Prerequisites: Computation expressions

A builder is an ordinary class with specially named methods. The compiler translates the keywords into calls to them:

You write The compiler calls
let! x = m then the rest builder.Bind(m, fun x -> rest)
return v builder.Return(v)
return! m builder.ReturnFrom(m)
an if with no else builder.Zero()

An option builder

type OptionBuilder() =
    member _.Bind(m, f) = Option.bind f m
    member _.Return(x) = Some x
    member _.ReturnFrom(m: 'a option) = m
    member _.Zero() = None

let option = OptionBuilder()

let tryParse (s: string) =
    match System.Int32.TryParse s with
    | true, n -> Some n
    | _ -> None

let addStrings a b =
    option {
        let! x = tryParse a
        let! y = tryParse b
        return x + y
    }

printfn "%A" (addStrings "2" "40")
printfn "%A" (addStrings "2" "forty")
Some 42
None

let option = OptionBuilder() creates the instance whose name you put in front of the braces.

A result builder

The same pattern for Result gives you validation pipelines that read top to bottom and stop at the first error:

type ResultBuilder() =
    member _.Bind(m, f) = Result.bind f m
    member _.Return(x) = Ok x
    member _.ReturnFrom(m: Result<_, _>) = m

let result = ResultBuilder()

type Order = { Product: string; Quantity: int; Total: decimal }

let parseQuantity (s: string) =
    match System.Int32.TryParse s with
    | true, n when n > 0 -> Ok n
    | _ -> Error $"invalid quantity '{s}'"

let lookupPrice product =
    match product with
    | "tea" -> Ok 3.5m
    | "coffee" -> Ok 4.0m
    | other -> Error $"unknown product '{other}'"

let createOrder product quantityText =
    result {
        let! quantity = parseQuantity quantityText
        let! price = lookupPrice product
        return { Product = product; Quantity = quantity; Total = price * decimal quantity }
    }

printfn "%A" (createOrder "tea" "3")
printfn "%A" (createOrder "tea" "-1")
printfn "%A" (createOrder "juice" "2")
Ok { Product = "tea"
     Quantity = 3
     Total = 10.5M }
Error "invalid quantity '-1'"
Error "unknown product 'juice'"

In real projects, use FsToolkit.ErrorHandling, which provides polished option, result, asyncResult, taskResult, and validation builders — but now you know there's no magic inside.

A logging builder

Builders can carry extra information along. This one collects log messages as the computation runs:

type Logged<'T> = { Value: 'T; Log: string list }

type LoggingBuilder() =
    member _.Bind(m: Logged<'a>, f: 'a -> Logged<'b>) =
        let next = f m.Value
        { Value = next.Value; Log = m.Log @ next.Log }
    member _.Return(x) = { Value = x; Log = [] }

let logged = LoggingBuilder()
let log msg x = { Value = x; Log = [ msg ] }

let calculation =
    logged {
        let! a = log "start with 5" 5
        let! b = log "double it" (a * 2)
        let! c = log "add 3" (b + 3)
        return c
    }

printfn "%d" calculation.Value
calculation.Log |> List.iter (printfn "- %s")
13
- start with 5
- double it
- add 3

Builders can also support for, while, try/with, use, and custom operations (like where in query { }) by adding more methods. The "Computation Expressions" page of the F# language reference lists them all.

  • Don't write builders for everything — a plain pipeline is often clearer. They shine when the "plumbing" (options, results, async) would otherwise bury the logic.