Result: A Boundary Against the Model's Responses
Estimated time: about 30 min.
The problem this module solves
In module 5, one line read the model's argument with a cast: (request.Arguments["orderId"] :?> JsonElement).GetInt32(). It worked because the model did send {"orderId":4512}. But the model is a data source you don't control: it can request a tool that doesn't exist, forget the argument, send "ABC-12" instead of a number, or produce truncated JSON.
This module builds a single boundary function that turns the tool name and the JSON text of the arguments into a typed value, or into an error that has a name. No model here: you work on fixed samples, so that every case is reproducible.
Why not deserialize directly?
The C# reflex would be JsonSerializer.Deserialize<OrderArguments>(json). Create naive.fsx to see what that gives:
// naive.fsx — module 06: deserializing the model's arguments directly.
open System.Text.Json
type OrderArguments = { orderId: int }
let attempt (argsJson: string) =
try
let value = JsonSerializer.Deserialize<OrderArguments> argsJson
printfn "%-20s -> %A" argsJson value
with ex ->
printfn "%-20s -> exception %s" argsJson (ex.GetType().Name)
attempt """{"orderId":4512}"""
attempt """{}"""
attempt """{"orderId":"ABC-12"}"""
attempt """{"orderId":45"""
"""...""" is an F# triple-quoted string: the inner quotes don't need escaping, and try ... with is C#'s try/catch. Run dotnet fsi naive.fsx. Output obtained with .NET 10:
{"orderId":4512} -> { orderId = 4512 }
{} -> { orderId = 0 }
{"orderId":"ABC-12"} -> exception JsonException
{"orderId":45 -> exception JsonException
The second line is the most dangerous one: a missing argument causes no error at all, it becomes 0. Your agent would look up order 0 and tell the customer it can't be found. The other two cases raise the same exception, without saying which of the problems occurred.
Another option would be to describe the tool request itself with a discriminated union and deserialize it. According to Microsoft Learn, System.Text.Json can serialize and deserialize F# discriminated unions starting with .NET 11; this course targets .NET 10 (LTS), where a union causes a NotSupportedException. The approach in this module, reading manually with JsonDocument, works regardless of the version.
Result: a two-case union, again
In C#, an operation that can fail for a specific reason is written either with an exception, or with the bool TryParse(string s, out int value) pattern — which tells you whether it failed, but not why. F# has a type for this, and you already know how to read it:
type Result<'T, 'E> =
| Ok of 'T
| Error of 'E
A Result<ToolCall, ToolError> is either Ok with a valid tool request, or Error with an error. And the error will itself be a discriminated union, so that each defect has its name.
The difference from Option: None says "nothing", Error says "nothing, and here's why".
The boundary's types
Create decoding.fsx with the two types and the first two steps, provided:
// decoding.fsx — module 06: the boundary between the model's data and your code.
// No package: System.Text.Json is part of .NET.
// This file prints nothing: it is loaded by samples.fsx and agent.fsx.
open System.Text.Json
// What your code agrees to execute: the closed list of allowed tools.
type ToolCall =
| GetOrder of orderId: int
// Everything that can be wrong in a tool request, with a name.
type ToolError =
| UnknownTool of name: string
| InvalidJson of raw: string
| MissingArgument of name: string
| BadArgumentType of name: string * received: string
// Step 1 — is the tool name on the allowed list?
let checkToolName (name: string) : Result<string, ToolError> =
if name = "getOrder" then Ok name else Error(UnknownTool name)
// Step 2 — is the arguments text a JSON object?
let readJson (argsJson: string) : Result<JsonElement, ToolError> =
try
use document = JsonDocument.Parse argsJson
if document.RootElement.ValueKind = JsonValueKind.Object then
Ok(document.RootElement.Clone())
else
Error(InvalidJson argsJson)
with :? JsonException ->
Error(InvalidJson argsJson)
ToolCall has only one case for now: it's the list of what your code agrees to execute. You'll see in module 8 that this closed type is your first guardrail.
use is the equivalent of C#'s using: the JSON document is released at the end of the function. readJson also contains a try ... with, and that's not a contradiction: JsonDocument.Parse is a .NET API that reports invalid JSON with an exception. It's caught here, once, for a single reason, and immediately turned into a named case. Past this line, no exception flows anymore.
Chaining steps that can fail
One third step remains — reading orderId — and then chaining all three. With nested match expressions, this would produce a staircase: if step 1 is Ok, then do step 2; if step 2 is Ok, then… F# provides two functions that do this work for you:
Result.bind step: if the value received isOk x, callsstep x(which itself returns aResult); if it'sError e, returnsError ewithout callingstep;Result.map f: if the value isOk x, returnsOk (f x); otherwise, lets the error through. It's used for a transformation that can't fail.
With the pipe |> from the first tier, the steps read from top to bottom, and the first error short-circuits everything else. This is what's called railway-oriented programming: two rails, Ok and Error, and each step can only move the value from the first to the second.
Exercise — complete the boundary
Add the following two functions to decoding.fsx, filling in their bodies:
// Step 3 — is the orderId argument present, and is it an integer?
let readOrderId (root: JsonElement) : Result<int, ToolError> =
// Your turn: MissingArgument if absent, BadArgumentType if not an integer, otherwise Ok
// The pipeline: the first error short-circuits the following steps.
let decode (name: string) (argsJson: string) : Result<ToolCall, ToolError> =
// Your turn: checkToolName, readJson, readOrderId, then build GetOrder
Hints, to use only if you get stuck:
root.TryGetProperty "orderId"returns a tuple(bool * JsonElement): F# turns .NET'soutparameter into the second element of the tuple;matchonfalse, _andtrue, value.- A value is an integer if
value.ValueKind = JsonValueKind.Numberandfst (value.TryGetInt32())is true (fstreturns the first element of a pair). Only testTryGetInt32after checking that it's a number: on a string, it throws an exception. For the text received,value.ToString()is enough. - In
decode,checkToolNamereturns the name, which the next step doesn't need:Result.bind (fun _ -> readJson argsJson). GetOrderis a union case, and therefore a functionint -> ToolCall:Result.map GetOrderends the pipeline.
Solution
let readOrderId (root: JsonElement) : Result<int, ToolError> =
match root.TryGetProperty "orderId" with
| false, _ -> Error(MissingArgument "orderId")
| true, value ->
let isInteger =
value.ValueKind = JsonValueKind.Number && fst (value.TryGetInt32())
if isInteger then
Ok(value.GetInt32())
else
Error(BadArgumentType("orderId", value.ToString()))
let decode (name: string) (argsJson: string) : Result<ToolCall, ToolError> =
checkToolName name
|> Result.bind (fun _ -> readJson argsJson)
|> Result.bind readOrderId
|> Result.map GetOrder
If you wrote decode with nested match expressions, that's a good first draft: it's correct. Now rewrite it with Result.bind — that's the target form, and the one the agent will use.
Exercise — predict then verify: five samples
Create samples.fsx. Each sample is a raw tool request, as a model might send it:
// samples.fsx — module 06: raw tool requests, as a model would send them.
#load "decoding.fsx"
open Decoding
let samples =
[
("getOrder", """{"orderId":4512}""")
("cancelOrder", """{"orderId":7781}""")
("getOrder", """{}""")
("getOrder", """{"orderId":"ABC-12"}""")
("getOrder", """{"orderId":45""")
]
for (name, argsJson) in samples do
printfn "%-11s %-20s -> %A" name argsJson (decode name argsJson)
Before running it, write down the result you expect for each of the five lines: Ok (...) or Error (...) with the exact case. Then run dotnet fsi samples.fsx and compare with the next section.
Expected output
getOrder {"orderId":4512} -> Ok (GetOrder 4512)
cancelOrder {"orderId":7781} -> Error (UnknownTool "cancelOrder")
getOrder {} -> Error (MissingArgument "orderId")
getOrder {"orderId":"ABC-12"} -> Error (BadArgumentType ("orderId", "ABC-12"))
getOrder {"orderId":45 -> Error (InvalidJson "{"orderId":45")
Five inputs, five distinct results, no exceptions. Note the second line: {"orderId":7781} is perfectly valid JSON, but the error is UnknownTool, because the name step comes first and short-circuits the rest. (%A doesn't escape inner quotes, hence how the last line looks.)
Exercise — see the short-circuit
Temporarily add this line at the very beginning of the body of readOrderId:
printfn " (readOrderId executed)"
How many times will this line be printed for the five samples, and before which ones? Write down your answer, run samples.fsx again, then compare.
What you should observe
(readOrderId executed)
getOrder {"orderId":4512} -> Ok (GetOrder 4512)
cancelOrder {"orderId":7781} -> Error (UnknownTool "cancelOrder")
(readOrderId executed)
getOrder {} -> Error (MissingArgument "orderId")
(readOrderId executed)
getOrder {"orderId":"ABC-12"} -> Error (BadArgumentType ("orderId", "ABC-12"))
getOrder {"orderId":45 -> Error (InvalidJson "{"orderId":45")
Three times. For cancelOrder, the error from step 1 prevented steps 2 and 3; for the truncated JSON, the error from step 2 prevented step 3. Result.bind didn't even call the function. Remove the trace line before moving on: decoding.fsx must not print anything, because the agent will load it.
Why not a try ... with around everything?
You could wrap module 5's cast in a try ... with _ -> "error". The program would no longer crash, but the five situations above would become a single silent error, and the missing argument — which doesn't raise an exception — would still pass as 0. With the boundary, each defect has a name that the rest of the code can handle differently: in the next module, that name will be sent back to the model so it can correct its request.
What you just did
You saw that direct deserialization silently turns a missing argument into 0, represented each possible defect of a tool request as a union case, and wrote a boundary decode : string -> string -> Result<ToolCall, ToolError> by chaining three steps with Result.bind and Result.map. You verified that the first error prevents the following steps from running, and that five different inputs give five distinct results without a single exception.
In the next module, everything comes together: the model, the tool, the boundary — and a third discriminated union that describes what the model produced on each turn of an agent loop.
Check your understanding
With `decode name argsJson = checkToolName name |> Result.bind (fun _ -> readJson argsJson) |> Result.bind readOrderId |> Result.map GetOrder`, what does `decode "getorder" """{"orderId":12"""` return (lowercase name and truncated JSON)?
The model requests `getOrder` with the arguments `{"orderId":12.5}`. What does your `decode` function return?
To save time, a colleague replaces `decode` with `try Ok (GetOrder (JsonSerializer.Deserialize<OrderArguments>(argsJson).orderId)) with _ -> Error "error"`. What's wrong with it?
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.