Aller au contenu principal
Nicolas Cousin Tech SolutionsNicolas Cousin Tech Solutions
Module 4 of 10

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:

  1. findOrder (id: int) : Order option, which looks up the order in the orders Map;
  2. getOrder (orderId: int) : string, which returns the order's description (using the describe function from module 3) if it exists, and otherwise exactly Order <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 describe applies describe only if there's an order: Some order becomes Some "Order ...", and None stays None;
  • Option.defaultValue text extracts the value from the Some, or returns text on None.

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.