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

Discriminated Unions: Modeling an Order's Status

Estimated time: about 15 min.

The problem this module solves

The tool the model will call, getOrder, has to say where an order stands. An order can be processing, shipped, delivered, or cancelled. But these states don't carry the same information: a shipped order has a tracking number, a delivered order has a delivery date, a cancelled order has a reason, and an order being processed has none of these.

This module doesn't use the model: you write pure F#, instant and deterministic. It's the business core that the agent will expose later.

What you'd write in C#

The most common C# reflex is an enum plus optional fields:

enum Status { Processing, Shipped, Delivered, Cancelled }

class Order
{
    public int Id { get; init; }
    public Status Status { get; init; }
    public string? Tracking { get; init; }       // only if Shipped
    public DateOnly? DeliveryDate { get; init; } // only if Delivered
    public string? Reason { get; init; }         // only if Cancelled
}

Nothing prevents a Processing order from having a Tracking, or a Shipped order from lacking one: the rule "tracking only exists if the order is shipped" lives in comments. The more rigorous C# alternative — an abstract base class and one subclass per state — works, but takes five classes, and the compiler won't tell you if a switch forgets one of them.

The discriminated union

In F#, this rule is written in the type itself:

open System

type Status =
    | Processing
    | Shipped of tracking: string
    | Delivered of date: DateOnly
    | Cancelled of reason: string

Read it as "a Status is either Processing, or Shipped with a tracking number, or Delivered with a date, or Cancelled with a reason". Each line is a case; what follows of is the data that this case carries, and only this case. A shipped order with no tracking number, or a processing order with one, is simply impossible to construct.

You construct a value by calling the case like a function:

let s1 = Processing
let s2 = Shipped "TRK-88"
let s3 = Delivered(DateOnly(2026, 10, 20))

And you consume it with match, which you know from the first tier. Each pattern names a case and decomposes its data into a variable:

let describeStatus (status: Status) : string =
    match status with
    | Processing -> "processing"
    | Shipped tracking -> $"shipped, tracking {tracking}"
    // ... one pattern per remaining case

In the Shipped tracking branch, the tracking variable exists and contains the number; in the other branches, it doesn't exist. There's no null field to test.

Exercise — complete orders.fsx

Create orders.fsx in your working folder. The Order type is a record, as in the first tier, one of whose fields is the union:

// orders.fsx — the assistant's domain: pure F#, no package, no model.
// This file only declares types and functions: it prints nothing,
// because it is loaded by other scripts (including the MCP server in module 09).

open System

// Module 03 — a discriminated union: a status has one shape out of four,
// and each shape carries exactly the data that belongs to it.
type Status =
    | Processing
    | Shipped of tracking: string
    | Delivered of date: DateOnly
    | Cancelled of reason: string

type Order = { Id: int; Status: Status }

let orders =
    Map [
        (4512, { Id = 4512; Status = Shipped "TRK-88" })
        (7781, { Id = 7781; Status = Processing })
        (3020, { Id = 3020; Status = Delivered(DateOnly(2026, 10, 20)) })
        (6034, { Id = 6034; Status = Cancelled "out of stock" })
    ]

let describeStatus (status: Status) : string =
    match status with
    | Processing -> "processing"
    | Shipped tracking -> $"shipped, tracking {tracking}"
    // Your turn: one pattern for Delivered, one pattern for Cancelled

let describe (order: Order) : string =
    $"Order {order.Id}: {describeStatus order.Status}"

Map [ (key, value); ... ] builds an immutable dictionary sorted by key, the F# equivalent of an ImmutableSortedDictionary; you'll use it in the next module to look up an order by its number.

Your turn to complete describeStatus so that the two remaining cases produce exactly these texts:

  • a delivered order: delivered on 2026-10-20 (the date in yyyy-MM-dd format);
  • a cancelled order: cancelled (out of stock).

To check, create a second script, statuses.fsx, which loads the first one and prints each order:

// statuses.fsx — module 03: print the description of each sample order.
#load "orders.fsx"

open Orders

for order in Map.values orders do
    printfn "%s" (describe order)

for ... in ... do is C#'s foreach, and Map.values returns the dictionary's values.

dotnet fsi statuses.fsx

Expected output (the orders come out sorted by number, because a Map is ordered by key):

Order 3020: delivered on 2026-10-20
Order 4512: shipped, tracking TRK-88
Order 6034: cancelled (out of stock)
Order 7781: processing

If you're stuck on the date: a DateOnly is formatted as in C#, with date.ToString("yyyy-MM-dd"). In an interpolated string $"...", quotes inside the braces require opening the string with three quotes: $"""delivered on {date.ToString("yyyy-MM-dd")}""".

Solution

let describeStatus (status: Status) : string =
    match status with
    | Processing -> "processing"
    | Shipped tracking -> $"shipped, tracking {tracking}"
    | Delivered date -> $"""delivered on {date.ToString("yyyy-MM-dd")}"""
    | Cancelled reason -> $"cancelled ({reason})"

Exercise — predict, then verify: the forgotten case

Delete the | Cancelled reason -> ... line from describeStatus, without changing anything else.

Before running statuses.fsx again, write down your answers to two questions:

  1. Will the compiler reject the script, accept it silently, or accept it with a message?
  2. If the script runs, what will happen when it reaches order 6034?

Then run dotnet fsi statuses.fsx again, and compare with your answers in the next section.

What you should observe

The compiler accepts the script, but emits an incomplete match warning, FS0025, which names the forgotten case. The message fits on a single line; it's split in two here for readability, and the file path is shortened:

orders.fsx(26,11): warning FS0025: Incomplete pattern matches on this expression.
For example, the value 'Cancelled (_)' may indicate a case not covered by the pattern(s).

Then execution starts, prints the first two orders, and stops on the third:

Order 3020: delivered on 2026-10-20
Order 4512: shipped, tracking TRK-88
Microsoft.FSharp.Core.MatchFailureException: The match cases were incomplete

(followed by the stack trace and Stopped due to error, shortened here)

The exact wording of the warning may vary with the compiler version; what doesn't vary is that it points to the missing case before execution. Restore the Cancelled line: the warning disappears and the output goes back to that of the previous exercise.

Why not add | _ -> ...?

To silence the warning, you could add a | _ -> "unknown status" wildcard as the last branch. On a union you own, that's a bad idea: the day you add a case — for example Returned —, the wildcard will silently swallow it, and the compiler will no longer be able to show you the match expressions to update. Without a wildcard, adding a case makes an FS0025 appear at every affected location: it's a free to-do list. You'll take advantage of it in module 8.

What you just did

You described an order status with four shapes, three of which carry data of different types (string, DateOnly), constructed values of each case, and wrote an exhaustive match that decomposes each case. You saw the compiler flag a forgotten case before execution, and understood why a wildcard on your own union disables that protection.

In the next module, you write the lookup of an order by its number. What do you return when the number doesn't exist? In C#, it would be null. In F#, it will be another discriminated union — one you already know without realizing it.

Check your understanding

A script declares `type Delivery = PickupPoint of point: string | HomeDelivery of address: string | StorePickup`, then a `label` function whose `match` only handles `PickupPoint` and `HomeDelivery`. The script ends with `label StorePickup`. What happens when you run it?

A parcel can be pending, handed over to the carrier (with the carrier's name), or lost (with the date it was reported). A colleague proposes a `string` for the state, two optional fields, and a `| _ -> "unknown"` in every `match` "just to be safe". What do you tell them?

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.