Every developer has been here: you’re reading a function and the logic keeps indenting. First you check if a value exists. If it does, you check if the next thing is valid. If that passes, you look something else up. By the time you get to the actual work, you’re four or five levels deep — and the mental overhead of tracking which branch you’re in, and what happens if any step fails, is exhausting.
This problem shows up constantly: validating user input, chaining database lookups, composing async API calls. The nesting isn’t a sign of bad code — it’s the natural shape of logic that needs to handle failure at every step. But natural doesn’t mean easy to read or maintain.
F# has a feature called computation expressions that solves this directly. Instead of stacking conditionals inside one another, you write each step on its own line — flat, sequential, like a straight-line script. The computation expression handles the branching logic for you: if any step fails or returns nothing, the whole block short-circuits and returns early. Your code describes what to do; the CE handles when to stop.
You don’t need to know F# to follow along. The sections below walk through real examples — chaining optional values, propagating errors, composing async calls — and show the before and after side by side. The goal is to show how computation expressions can make complex branching logic simpler to read, simpler to write, and simpler to change.
Options
F#’s option type represents a value that may or may not be present. It’s the principled alternative to null. A value is either Some x or None, and the type system forces you to handle both cases.
There are a couple of ways to handle this without a computation expression, and both have trade-offs.
// Imagine a small domain: users have an active subscription, subscriptions have a promo codetype PromoCode = { Code: string; DiscountPct: int}type Subscription = { Plan: string; PromoCode: PromoCode option}type User = { Name: string; Subscription: Subscription option}let findUser (id: int) : User option =if id = 1then Some { Name = "Alice"; Subscription = Some { Plan = "Pro"; PromoCode = Some { Code = "SAVE20"; DiscountPct = 20}}}else None// --- Before: cascading match expressions ---// Each optional hop requires its own match arm. Nesting grows with the chain.let getDiscountNestedMatch (userId: int) : intoption =match findUser userId with | None -> None | Some user ->match user.Subscription with | None -> None | Some sub ->match sub.PromoCode with | None -> None | Some promo -> Some promo.DiscountPctprintfn "Nested match: %A"(getDiscountNestedMatch 1) // Some 20printfn "Nested match: %A"(getDiscountNestedMatch 99) // None// --- Before: Option.bind ---// Option.bind f opt evaluates to: match opt with None -> None | Some x -> f x// Piping through Option.bind flattens the nesting, but the lambda noise adds up.let getDiscountOptionBind (userId: int) : intoption = findUser userId |> Option.bind (fun user -> user.Subscription) |> Option.bind (fun sub -> sub.PromoCode) |> Option.map (fun promo -> promo.DiscountPct)printfn "Option.bind: %A"(getDiscountOptionBind 1) // Some 20printfn "Option.bind: %A"(getDiscountOptionBind 99) // None
Nested match: Some 20
Nested match: None
Option.bind: Some 20
Option.bind: None
// --- With the option computation expression ---// `let!` unwraps a `Some`, or short-circuits with `None` if the value is absent.// The happy path reads top-to-bottom with no nesting.let getDiscountWithCE (userId: int) : intoption =option{let! user = findUser userIdlet! sub = user.Subscriptionlet! promo = sub.PromoCodereturn promo.DiscountPct}printfn "option CE: %A"(getDiscountWithCE 1) // Some 20printfn "option CE: %A"(getDiscountWithCE 99) // None
The logic is identical across all three, but each approach makes a different trade-off. Nested match is maximally explicit but creates a rightward drift that gets worse with every hop. Option.bind flattens the structure using a pipeline, which is better — but you’re still writing a lambda for each step, and the ceremony starts to obscure what you’re actually computing. The computation expression eliminates both problems: let! unwraps each option and short-circuits on None, while the block reads straight down like ordinary sequential code.
This pattern scales cleanly. Whether you’re unwrapping two optional values or ten, the structure stays flat.
Results
The result type takes the option idea further: instead of just signalling absence with None, it carries an error value when something goes wrong. A result is either Ok value or Error err, and like option, the type system forces you to handle both cases.
Chaining fallible operations has the same structural problem as chaining optional ones — each step might fail, and you want to short-circuit on the first error. The result CE from FsToolkit.ErrorHandling handles this cleanly.
Note: Unlike option, the result CE is not in the F# standard library. It comes from FsToolkit.ErrorHandling, a widely-used community package that adds computation expression support for Result, Async, and more.
// A small validation pipeline: parse an order from raw inputtype OrderError = InvalidId | NegativeQuantity | UnknownProducttype Order = { Id: int; ProductId: int; Quantity: int}let parseId (raw: string) : Result<int, OrderError> =match System.Int32.TryParse raw with | true, n when n > 0 -> Ok n | _ -> Error InvalidIdlet validateQuantity (qty: int) : Result<int, OrderError> =if qty > 0then Ok qty else Error NegativeQuantitylet lookupProduct (productId: int) : Result<int, OrderError> =if productId = 42then Ok productId else Error UnknownProduct// --- Before: cascading match expressions ---let parseOrderNestedMatch (rawId: string)(qty: int)(productId: int) : Result<Order, OrderError> =match parseId rawId with | Error e -> Error e | Ok id ->match validateQuantity qty with | Error e -> Error e | Ok validQty ->match lookupProduct productId with | Error e -> Error e | Ok pid -> Ok { Id = id; ProductId = pid; Quantity = validQty }printfn "Nested match: %A"(parseOrderNestedMatch "7"342) // Ok ...printfn "Nested match: %A"(parseOrderNestedMatch "7"-142) // Error NegativeQuantity// --- Before: Result.bind ---// Flattens the nesting but requires threading intermediate values manually.let parseOrderResultBind (rawId: string)(qty: int)(productId: int) : Result<Order, OrderError> = parseId rawId |> Result.bind (fun id -> validateQuantity qty |> Result.bind (fun validQty -> lookupProduct productId |> Result.map (fun pid -> { Id = id; ProductId = pid; Quantity = validQty })))printfn "Result.bind: %A"(parseOrderResultBind "7"342) // Ok ...printfn "Result.bind: %A"(parseOrderResultBind "abc"342) // Error InvalidId
// --- With the result computation expression (FsToolkit.ErrorHandling) ---// `let!` unwraps Ok, or short-circuits with the Error value.// All intermediate values stay in scope — no manual threading.let parseOrderCE (rawId: string)(qty: int)(productId: int) : Result<Order, OrderError> = result {let! id = parseId rawIdlet! validQty = validateQuantity qtylet! pid = lookupProduct productIdreturn{ Id = id; ProductId = pid; Quantity = validQty }}printfn "result CE: %A"(parseOrderCE "7"342) // Ok { Id = 7; ProductId = 42; Quantity = 3 }printfn "result CE: %A"(parseOrderCE "abc"342) // Error InvalidIdprintfn "result CE: %A"(parseOrderCE "7"399) // Error UnknownProduct
The Result.bind version avoids the rightward drift of nested matches, but notice that threading id and validQty into the final Result.map requires nesting the lambdas anyway — you can’t easily keep all the bound values in scope when chaining with |>. The result { } CE solves both problems at once: it’s flat, and every let! binding is naturally in scope for the rest of the block.
Async
F#’s async { } computation expression is the built-in way to write asynchronous code. It predates C#’s async/await and works on the same idea: let! suspends the current workflow until an async operation completes, then binds the result — without blocking a thread.
Without the CE, composing async operations means chaining Async.bind calls. Like Result.bind, this works for a single hop but gets unwieldy as soon as you need to carry multiple values forward.
Note: The async computation expression is part of the F# standard library.
// Simulated async data layertype User = { Id: int; Name: string}type Product = { Id: int; Name: string; Price: float}type Cart = { UserId: int; ProductIds: int list }let fetchUser (id: int) : Async<User> = async {return{ Id = id; Name = "Alice"}}let fetchCart (userId: int) : Async<Cart> = async {return{ UserId = userId; ProductIds = [1; 2; 3]}}let fetchProduct (productId: int): Async<Product> = async {return{ Id = productId; Name = "Widget"; Price = 9.99}}// --- Before: Async.bind ---// Each hop needs its own Async.bind. Threading `user` and `cart` into// the final step requires nesting — the same problem as Result.bind.let getCartTotalBind (userId: int) : Async<float> = fetchUser userId |> Async.bind (fun user -> fetchCart user.Id |> Async.bind (fun cart -> cart.ProductIds |> List.map fetchProduct |> Async.Sequential |> Async.map (fun products -> products |> Array.sumBy (fun p -> p.Price))))getCartTotalBind 1 |> Async.RunSynchronously |> printfn "Async.bind: %.2f"
// --- With the async computation expression ---// `let!` suspends until each async completes and binds the result.// All values stay in scope — no nesting required.let getCartTotalCE (userId: int) : Async<float> = async {let! user = fetchUser userIdlet! cart = fetchCart user.Idlet! products = cart.ProductIds |> List.map fetchProduct |> Async.Sequentialreturn products |> Array.sumBy (fun p -> p.Price)}getCartTotalCE 1 |> Async.RunSynchronously |> printfn "async CE: %.2f"
The structure mirrors the result CE: let! on each async step, all bindings in scope, linear top-to-bottom flow. The Async.bind version forces nesting once you need to reference user and cart together in the final step — the CE keeps everything flat.
One thing worth noting: F#’s async { } does not use the Task<T> type that C# async/await produces. If you’re interoperating with .NET libraries that return Task, you can use task { } instead — it’s the same CE pattern, but backed by Task<T> rather than F#’s Async<T>.
Query Expressions
Computation expressions aren’t limited to error handling. F# also has a built-in query { } CE that brings LINQ-style querying directly into the language — no extension methods, no lambda-heavy syntax.
let highValueOrders = query {for order in orders do where (order.Total > 100.0) sortByDescending order.Total select order}
This is not special syntax baked into the compiler — query is just another computation expression builder, defined in the standard library. for, where, sortByDescending, and select are all methods on the builder that the compiler desugars into IQueryable-compatible calls. The same mechanism that powers option { } and result { } powers SQL-translatable database queries.
That’s the deeper point: computation expressions are a general-purpose abstraction. Once you understand the pattern — a builder type with Bind, Return, and friends — you can read (and write) any CE in the ecosystem, whether it’s for async workflows, error handling, query composition, or something domain-specific you’ve built yourself.
Conclusion
The examples in this post cover four different problems — missing values, validation errors, async operations, and data queries — but the underlying pattern is the same every time. Without a computation expression, handling failure at each step means nesting: every check wraps the next one, and the actual logic gets buried in indentation. With a computation expression, the same code reads top to bottom, one step per line, with the branching logic handled invisibly by the CE.
That’s the practical case for computation expressions: not that they do something new, but that they make existing logic easier to follow. The code becomes shorter, yes — but more importantly, it becomes easier to reason about. You can read a result { } block and immediately see the sequence of operations without mentally tracking which match branch you’re inside.
If you’re coming from another language and evaluating F#, computation expressions are worth paying attention to. Most of what they give you — flat error propagation, readable async code, composable validation — requires third-party libraries or language extensions elsewhere. In F#, it’s a first-class feature of the language itself, and one that quietly improves code quality across the board.