Tagless Final in F# — A 6-part series on building DSLs with Computation Expressions.
- Froggy Tree House
- Maps, Branches, and Choices ← you are here
- Goals, Threats, and Getting Stuck
- A Surprising New DSL: Elevators
- Verifying the Elevator
- The Power of Tagless-Final: Code as Model
Maps, Branches, and Choices: Nondeterminism Arrives
Welcome back! In the last post, we built a tiny DSL to control Froggy. But there was a problem: Froggy was a robot. He followed a strict, linear script.
frog {
jump
croak
eat_fly
}
Real trees aren’t linear. They branch. Sometimes Froggy has to make a choice: should he jump to the left branch or the right branch? Should he eat the fly or save it for later?
Today, we’re going to introduce Nondeterminism.
The Fork in the Road
We want to be able to write something like this:
let exploration = frog {
jump
choose [
frog { croak }
frog { jump; eat_fly }
]
}
Here, choose means “do any of these things”. It splits the timeline. In one future, Froggy croaks. In another, he jumps and eats.
Updating the Interpreter Definition
To support this, we need to add Choose to our interpreter record.
type FrogInterpreter<'a> = {
Jump : unit -> 'a
Croak : unit -> 'a
EatFly : unit -> 'a
// The new power!
Choose : 'a list -> 'a
Bind : 'a -> (unit -> 'a) -> 'a
Return : unit -> 'a
}
And we update our builder to support a choose custom operation (or just a helper function).
// Helper for the DSL
let choose options = fun (i: FrogInterpreter<'a>) ->
let interpretedOptions = options |> List.map (fun opt -> opt i)
i.Choose interpretedOptions
Interpreter 3: The Multiverse Simulator
Remember our simulator from last time? It returned a FrogState -> FrogState.
If we want to support choice, we can’t return just one state anymore. We need to return all possible states.
So our new return type is FrogState -> list<FrogState>.
let multiverseSimulator : FrogInterpreter<FrogState -> list<FrogState>> = {
// Basic actions now return a list of one result
Jump = fun () -> fun s -> [ { s with Height = s.Height + 1 } ]
Croak = fun () -> fun s -> [ s ]
EatFly = fun () -> fun s -> [ { s with Hunger = 0 } ]
Return = fun () -> fun s -> [ s ]
// Bind is tricky! We have to apply 'next' to ALL results from 'prev'
Bind = fun prev next -> fun s ->
let possibleStates = prev s
// For every possible state we ended up in...
possibleStates |> List.collect (fun s' ->
// ...run the next step of the program
let nextAction = next()
nextAction s'
)
// Choose just concatenates the possibilities
Choose = fun options -> fun s ->
options |> List.collect (fun opt -> opt s)
}
Now if we run exploration with this interpreter, we get a list of all possible outcomes!
Interpreter 4: The Cartographer (Graph Builder)
Simulating states is cool, but it’s hard to visualize. What if we want to draw a map of all possible paths Froggy can take?
We can build a Graph Interpreter. This interpreter will produce a list of Nodes and Edges, suitable for a tool like GraphViz (DOT format).
type GraphNode = { Id: int; Label: string }
type GraphEdge = { From: int; To: int; Label: string }
type Graph = { Nodes: GraphNode list; Edges: GraphEdge list; CurrentId: int }
let emptyGraph = { Nodes = []; Edges = []; CurrentId = 0 }
let graphBuilder : FrogInterpreter<Graph -> Graph> = {
Jump = fun () -> fun g ->
let newId = g.CurrentId + 1
let node = { Id = newId; Label = "Jump" }
let edge = { From = g.CurrentId; To = newId; Label = "" }
{ g with Nodes = node :: g.Nodes; Edges = edge :: g.Edges; CurrentId = newId }
Croak = fun () -> fun g ->
let newId = g.CurrentId + 1
let node = { Id = newId; Label = "Croak" }
let edge = { From = g.CurrentId; To = newId; Label = "" }
{ g with Nodes = node :: g.Nodes; Edges = edge :: g.Edges; CurrentId = newId }
EatFly = fun () -> fun g ->
let newId = g.CurrentId + 1
let node = { Id = newId; Label = "EatFly" }
let edge = { From = g.CurrentId; To = newId; Label = "" }
{ g with Nodes = node :: g.Nodes; Edges = edge :: g.Edges; CurrentId = newId }
Return = fun () -> id
Bind = fun prev next -> fun g ->
let g' = prev g
let nextAction = next()
nextAction g'
Choose = fun options -> fun g ->
// For a graph, we create a branch point and merge all paths
let branchId = g.CurrentId
options
|> List.fold (fun acc opt ->
let result = opt { acc with CurrentId = branchId }
{ Nodes = result.Nodes @ acc.Nodes
Edges = result.Edges @ acc.Edges
CurrentId = max result.CurrentId acc.CurrentId }
) g
}
Now we can visualize Froggy’s decision tree!
The Power of Multiple Interpretations
Let’s step back and appreciate what we’ve built.
We wrote exploration once:
let exploration = frog {
jump
choose [
frog { croak }
frog { jump; eat_fly }
]
}
And we can interpret it in four different ways:
| Interpreter | Result Type | Purpose |
|---|---|---|
storyTeller |
string |
Generate narrative text |
simulator |
FrogState -> FrogState |
Single-path simulation |
multiverseSimulator |
FrogState -> list<FrogState> |
All possible outcomes |
graphBuilder |
Graph -> Graph |
Visualize decision tree |
The same code, four different meanings. No rewrites. No adapters. Just swap the interpreter.
What’s Next?
Froggy can now explore the multiverse. But life isn’t all rainbows and flies. Sometimes there are snakes. Sometimes you reach the top of the tree.
In the next post, we’ll add Goals and Threats to our DSL, and build interpreters that can detect danger before it happens!
This post is part of FsAdvent 2025.
| « Previous: Froggy Tree House | Next: Goals, Threats, and Getting Stuck » |