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.