#do and Thenables
Chapter 7 said what do is not. This chapter says what it is. do is syntax for
chaining computations that carry a context: a value that might be missing, a value
that might be an error, a computation with several possible answers. Inside a do
block, the chaining reads like ordinary sequential code and the context handling
disappears.
No do block in this chapter performs IO. Where an example prints, the printing
happens in main, outside the chain.
#The problem
Three lookups, each of which can fail. Written by hand, the plumbing is most of the program:
import map.{Map, get}
import map as M
prices : Map String Float
prices = M.fromList [("cafe", 4.5), ("books", 18.0)]
stock : Map String Int
stock = M.fromList [("cafe", 3), ("books", 0)]
lineTotal : String -> Option Float
lineTotal name = match get name prices
None => None
Some p => match get name stock
None => None
Some n => Some (p * fromInt n)
main =
println (lineTotal "cafe")
println (lineTotal "tea")Some 13.5
NoneTwo lookups, four lines of match, and the part that matters, p * fromInt n, is at
the bottom of a staircase. A third lookup makes it worse.
#The same thing with do
import map.{Map, get}
import map as M
prices : Map String Float
prices = M.fromList [("cafe", 4.5), ("books", 18.0)]
stock : Map String Int
stock = M.fromList [("cafe", 3), ("books", 0)]
lineTotal : String -> Option Float
lineTotal name = do
p <- get name prices
n <- get name stock
pure (p * fromInt n)
main =
println (lineTotal "cafe")
println (lineTotal "tea")Some 13.5
NoneSame output, no staircase. The block uses three new things:
doopens the block. Its statements are chained rather than merely run in order.<-binds through the context.p <- get name pricesmeans: if the lookup producedSome p, continue withpin scope; if it producedNone, stop here and make the whole block's valueNone. You never write the failing branch.puregoes the other way, wrapping an ordinary value back into the context.p * fromInt nis aFloat;pure (p * fromInt n)is anOption Float, which is whatlineTotalreturns.
A do block can also contain plain let bindings for values that need no context.
⚠️ A
doblock has to end in an expression, not alet. The last statement is the block's value, and aletis not a value. The type checker currently accepts a block that ends inlet, and the program then fails at run time with an unhelpful message, so this is one to catch by eye: if the last line of adoblock starts withlet, thepure …is missing.
#Result, and the running example
Option says "no value". Result e a says "no value, and here is why". It chains
under do the same way, carrying the first Err out of the block.
Parsing a ledger line is a chain of steps that can fail: split it, read four fields,
turn one into a number and another into a Category. Any step can fail, and the
caller wants to know which one did.
import string.{split, trim, toFloat}
import list.{get}
data Category = Food | Housing | Books deriving (Debug)
data Expense = {
date : String,
payee : String,
amount : Float,
category : Category,
}
impl Display Category where
display Food = "food"
display Housing = "housing"
display Books = "books"
impl Display Expense where
display e = "\{e.date} \{e.payee} $\{e.amount} (\{e.category})"
field : Int -> List String -> Result String String
field i parts = match get i parts
Some s => Ok (trim s)
None => Err "missing field \{i}"
amountOf : String -> Result String Float
amountOf s = match toFloat s
Some f => Ok f
None => Err "not a number: \{s}"
categoryOf : String -> Result String Category
categoryOf "food" = Ok Food
categoryOf "housing" = Ok Housing
categoryOf "books" = Ok Books
categoryOf other = Err "unknown category: \{other}"
parseExpense : String -> Result String Expense
parseExpense line = do
let parts = split "," line
date <- field 0 parts
payee <- field 1 parts
raw <- field 2 parts
amount <- amountOf raw
catName <- field 3 parts
category <- categoryOf catName
pure Expense {
date = date,
payee = payee,
amount = amount,
category = category,
}
report : String -> <IO> Unit
report line = match parseExpense line
Ok e => println e
Err m => println "skipped: \{m}"
main =
report "2026-08-01, Cafe Fish, 4.50, food"
report "2026-08-02, Landlord, lots, housing"
report "2026-08-03, Bookshop, 18.0"2026-08-01 Cafe Fish $4.5 (food)
skipped: not a number: lots
skipped: missing field 3parseExpense reads as six plain steps. There are six places it can fail, one per
<-, and none of them is written out. Each <- is a point where the block can stop
and hand the failing step's Err to the caller. The second and third lines of output
are two different steps failing.
Note where the seam is. parseExpense is pure and returns a Result. report is
the <IO> function that decides what to do with it. Keeping the chain pure and the
decision at the edge is the normal arrangement, and it is why chapter 7's file
reading and this chapter's parsing fit together without either knowing about the
other.
#do works for any Thenable
do is not built into Option or Result. It is sugar over two interface methods,
andThen from Thenable and pure from Applicative, so a function written with
do and a Thenable constraint works for every type that implements them:
both : Thenable m => m Int -> m Int -> m Int
both ma mb = do
a <- ma
b <- mb
pure (a + b)
main =
println (both (Some 1) (Some 2))
println (both (None : Option Int) (Some 2))
println (both (Ok 1 : Result String Int) (Err "boom"))
println (both [1, 2] [10, 20])Some 3
None
Err boom
[11, 21, 12, 22]One function, four behaviors. The fourth shows that do is not only about failure.
List is a Thenable too, and its andThen tries every combination, so
both [1, 2] [10, 20] produces all four sums. "Chain in a context" is the general
idea. "Stop at the first failure" is what the context happens to do for Option and
Result.
In the prelude, Option, Result e, and List implement Thenable, which
requires Applicative, which requires Mappable. Writing your own, for a parser or
a state threader, is an ordinary impl in the style of chapter 5. The laws such an
implementation should satisfy are beyond this guide. The practical rule is that do
behaves the way you expect as long as your andThen does nothing but sequence.
Coming from Haskell?
andThenis>>=with the arguments swapped so the value comes first,pureispure, andThenableisMonadwithout theFunctor/Applicative/Monadnames. There is noIOinstance, on purpose. IO is chapter 7's plain block.
The last two chapters cover how to organize a program across files, and the tools that check, format, and test what you write.