Option: An Order That Doesn't Exist
Estimated time: about 12 min.
The problem this module solves
Sooner or later, a customer will ask "where is my order 9999?", and order 9999 doesn't exist. The function that looks up an order must be able to answer "nothing found" — and the code that calls it must not be able to forget that case.
This module, still without the model, finishes orders.fsx with the getOrder function the agent will expose from module 5 onward.
What you'd write in C#
Order? FindOrder(int id) =>
orders.FirstOrDefault(o => o.Id == id);
var status = FindOrder(9999).Status; // NullReferenceException
Absence is encoded by null. C#'s nullability annotations help, but they remain warnings that can be ignored or suppressed with !, and they don't apply in the same way to all existing code.
Option: a union you already know
F# represents a possibly missing value with the Option type. Its definition, simplified, fits in three lines — it's a discriminated union like the one in the previous module:
type Option<'T> =
| Some of 'T
| None
An Order option (the type is generally written with option after, like int list) is therefore either Some with an order, or None. To read the order, you have to go through a match — and the compiler will flag an FS0025 if you forget None, exactly as for Status.
The standard library already returns options where C# would return null or throw an exception. For a Map, it's Map.tryFind:
Map.tryFind 4512 orders // Some { Id = 4512; ... }
Map.tryFind 9999 orders // None
Exercise — write the lookup and getOrder
Add two functions at the end of orders.fsx:
findOrder (id: int) : Order option, which looks up the order in theordersMap;getOrder (orderId: int) : string, which returns the order's description (using thedescribefunction from module 3) if it exists, and otherwise exactlyOrder <number> not found.
Constraints: no null, and no Option.get or .Value — these two shortcuts throw an exception on None, which brings back exactly the FirstOrDefault problem.
To check, create lookup.fsx:
// lookup.fsx — module 04: look up orders that exist… or not.
#load "orders.fsx"
open Orders
printfn "%A" (findOrder 7781)
printfn "%A" (findOrder 9999)
for number in [ 4512; 7781; 9999 ] do
printfn "%s" (getOrder number)
dotnet fsi lookup.fsx
Expected output:
Some { Id = 7781
Status = Processing }
None
Order 4512: shipped, tracking TRK-88
Order 7781: processing
Order 9999 not found
The first two lines show the raw values, as %A prints them: a Some wrapping the record, then None. The next three are the texts the model will receive when it requests the tool.
If you're stuck: in getOrder, match on findOrder orderId, with a Some order -> ... branch and a None -> ... branch.
Solution
// Module 04 — Option: a lookup that may find nothing.
let findOrder (id: int) : Order option =
Map.tryFind id orders
// The function the model will be able to ask us to call (modules 05 to 09).
let getOrder (orderId: int) : string =
match findOrder orderId with
| Some order -> describe order
| None -> $"Order {orderId} not found"
A variant without match: Option.map and Option.defaultValue
The Option module offers functions that handle both cases for you. The same getOrder can be written as a pipeline:
let getOrder (orderId: int) : string =
findOrder orderId
|> Option.map describe
|> Option.defaultValue $"Order {orderId} not found"
Option.map describeappliesdescribeonly if there's an order:Some orderbecomesSome "Order ...", andNonestaysNone;Option.defaultValue textextracts the value from theSome, or returnstextonNone.
The two forms are equivalent. The match is more explicit when you're starting out; the pipeline becomes natural when steps chain together. Keep whichever version you prefer: the following modules only use the getOrder : int -> string signature.
What you just did
You represented "maybe an order" with the Order option type, produced that type with Map.tryFind, and consumed both cases with no null and no unchecked access — with a match, then with Option.map and Option.defaultValue. You also saw that Option isn't a separate mechanism: it's a two-case discriminated union. Your orders.fsx is now complete; it won't change again.
In the next module, you pick up the model from module 2 again and give it getOrder as a tool. You'll see, with the real .NET types, the exchange from module 1: the model asks, your code runs.
Check your understanding
Four versions of `priceOf (sku: string) : string` use `Map.tryFind sku prices`. Which one correctly handles a missing SKU, with no risk of an exception and no sentinel value?
With `let promoCodes = Map [ ("XMAS", 10) ]` and `let discount code = Map.tryFind code promoCodes`, what is the value of `discount "SUMMER" |> Option.map (fun pct -> pct * 2) |> Option.defaultValue 0`?
You write `match findCustomer id with | Some c -> c.Name` and forget the `None` branch. What does the compiler tell you, and why?
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.