Wrap-up: Choosing the Right Type, Switching Models
Estimated time: about 12 min (excluding the optional part).
Where you stand
Nine modules ago, you had never called an LLM from F#. Your working folder now contains a complete agent, and each of the three types promised in the first tier plays a specific role in it:
| Type | Where it appears | What it expresses |
|---|---|---|
| Discriminated union | Status (module 3) |
an order is in one state out of four, each with its own data |
Option |
findOrder (module 4) |
the order may not exist, and that's normal |
Result + error union |
decode and ToolError (module 6) |
the model's request may be invalid, for a named reason |
| Discriminated union | AgentStep (module 7) |
on each turn, the model produced one shape out of three |
Result + error union |
runAgent and AgentError (modules 7 and 8) |
the agent may stop without an answer, for a named reason |
And around these types, the pieces of the agent you wrote: a call to a local model through IChatClient (module 2), a tool round trip (module 5), a bounded loop (module 7), typed guardrails and replayable failures (module 8), an MCP server (module 9).
The choice rule
The three types answer three different questions:
- Can the value be absent, without that being an error, and without any reason to explain? →
Option. "No order 9999" is a normal answer. - Can the operation fail, and does the caller need to know why? →
Result<'T, 'E>, with a union for'E. "Missing argument" and "invalid JSON" aren't handled the same way. - Does the value have several business shapes, all legitimate, with different data? → a discriminated union of your own. "Shipped with tracking" is neither an absence nor an error.
The most common trap is choosing by vocabulary: the word "error" or "failure" in the problem statement isn't enough to justify Result, and "not found" isn't enough to justify Option. Ask yourself the question of the reason: does someone downstream need to know it in order to act differently?
Exercise — choosing outside e-commerce
For each situation, choose Option, Result, or a discriminated union of your own, and write the deciding reason in one sentence. Sketch the type if you like. Don't read the answers before you've written all four.
- Reading the listening port from a configuration file. The key may be missing, contain
"abc", or contain70000(outside the 1–65535 range). The application must display a message that says precisely what to fix. - The status of a payment. A payment can be pending, authorized (with an amount), declined (with a reason provided by the bank), or refunded (with a date).
- An in-memory cache. You look up a computation result by its key; if it isn't there, you recompute it and store it.
- Looking up a user by email in a remote directory. The user may not exist; the directory may also be unreachable, or refuse the connection because of expired credentials.
Answers
Result, with an error union such asMissingPort | NonNumericPort of string | PortOutOfRange of int. AnOptionwould be tempting ("no valid port"), but it would lose what the statement requires: saying what to fix. There are three different reasons, hence anErrorthat names them.- A discriminated union:
Pending | Authorized of amount: decimal | Declined of reason: string | Refunded of date: DateOnly. The word "declined" sounds like a failure, but a declined payment is a legitimate business state, with its own data — exactly likeCancelledfor an order. It isn't a program error. Option: a missing cache entry is a normal, expected case, with no reason to explain — the caller recomputes no matter what.Resultwith a "CacheMiss" error would add a reason nobody uses.- Both, nested:
Result<User option, DirectoryError>. "The user doesn't exist" is a normal answer from the directory (Ok None); "directory unreachable" and "expired credentials" are failures with a reason, which call for different reactions (retry later, alert operations). This is the deliberately ambiguous situation: if you answeredOptionalone, you're confusing "nobody" with "outage"; if you answeredResultalone with aUserNotFounderror, you're treating a normal case as an error. A simpler answer (Option alone, or Result alone) can be defended in a specific context; what matters is knowing what each one confuses.
Switching models without touching the agent (optional)
Since module 2, a single line of the course knows which model you're using: let model = "qwen3.5:4b", in llm.fsx. Everything else only talks to IChatClient. Check it if you have another Ollama model capable of using tools (ollama list shows them): change only that line, then run dotnet fsi ask.fsx again without modifying agent.fsx.
If the new model does request the tool, the output keeps the same structure as in module 7 — turn markers, decoded requests, getOrder results —, only the final answers change. While preparing the course, this one-line change was verified with another tool-capable Ollama model. Expect very different durations: a bigger model is noticeably slower on a CPU alone. This course doesn't recommend any model other than qwen3.5:4b, the only one whose behavior was measured.
Toward a hosted provider. The same principle should apply to a hosted model: you'd need a library that provides an IChatClient implementation for that provider, and to replace the body of createClient with its construction (with an address and an API key); agent.fsx, decoding.fsx, orders.fsx, and the MCP server have no reason to change. This variant wasn't tested in this course: it usually requires an API key, often a paid one, some of createOptions's options (such as turning off reasoning) may be handled differently depending on the provider, and it sends the conversation history off your machine — reread the "confidentiality" item in module 8 before trying it.
The reason this works fits in one sentence: your code depends on an interface (IChatClient) and on your own types (AgentStep, ToolCall, AgentError), never on a provider. It's the same discipline as dependency injection in C#, applied to a model.
What you just did
You connected each of the course's types to the question it solves, and you chose between them in four situations unrelated to online commerce, including one where a simpler answer could be defended. You also saw why switching models should touch only llm.fsx: the rest of the code only knows an interface and your own types — something to check for yourself if you do the optional part.
To keep going
This tier deliberately stops short of several topics. They're only named here, so you know where to look:
- Testability and mocking in F#: how to structure an agent to test it properly — module 8's fake client was only a glimpse.
- RAG (retrieval-augmented generation): giving the model relevant documents for each question, instead of a single tool.
- Agent frameworks (Microsoft Agent Framework, Semantic Kernel…): they automate the loop you wrote. You'll now know what they do for you.
- System.Text.Json and discriminated unions: according to Microsoft Learn, System.Text.Json can serialize and deserialize F# unions starting with .NET 11 (this course targets .NET 10), which will change part of module 6; the
Resultboundary will remain useful, because it names each defect instead of throwing an exception.
You now have everything you need to build a .NET agent — in F#, and without a single line of Python.
Check your understanding
A function looks up an address's coordinates in a local embedded database. If the address isn't there, the caller simply displays "address not found" — yet the documentation talks about a "geocoding failure". Which return type do you choose?
A document is either a draft, in review (with the reviewer's name), published (with a URL), or archived (with a date). A colleague suggests `Result<PublicationUrl, string>`, with the error describing "why it isn't published". What do you choose?
A colleague wants to try the agent with another model, local or hosted. Why should this change touch only `llm.fsx`, and what remains to be checked?
Want to hear about the next modules?
The Academy stays free and open-access, no sign-up required. If you'd just like to be notified by email when a new module ships, here you go — no obligation, unsubscribe anytime with one click.