Unions discriminées : modéliser le statut d'une commande
Durée indicative : environ 15 min.
Le problème que ce module résout
L'outil que le modèle appellera, getOrder, doit dire où en est une commande. Une commande peut être en préparation, expédiée, livrée ou annulée. Mais ces états ne portent pas les mêmes informations : une commande expédiée a un numéro de suivi, une commande livrée a une date de livraison, une commande annulée a un motif, et une commande en préparation n'a rien de tout cela.
Ce module ne fait pas appel au modèle : vous écrivez du F# pur, instantané et déterministe. C'est le cœur métier que l'agent exposera plus tard.
Ce que vous écririez en C#
Le réflexe C# le plus courant est un enum plus des champs facultatifs :
enum Statut { EnPreparation, Expediee, Livree, Annulee }
class Commande
{
public int Id { get; init; }
public Statut Statut { get; init; }
public string? Suivi { get; init; } // seulement si Expediee
public DateOnly? DateLivraison { get; init; } // seulement si Livree
public string? Motif { get; init; } // seulement si Annulee
}
Rien n'empêche une commande EnPreparation d'avoir un Suivi, ni une commande Expediee d'en être privée : la règle « le suivi n'existe que si la commande est expédiée » vit dans des commentaires. L'alternative C# plus rigoureuse — une classe de base abstraite et une sous-classe par état — fonctionne, mais demande cinq classes, et le compilateur ne vous dira pas si un switch oublie l'une d'elles.
L'union discriminée
En F#, cette règle s'écrit dans le type lui-même :
open System
type Statut =
| EnPreparation
| Expediee of suivi: string
| Livree of date: DateOnly
| Annulee of motif: string
Lisez-le comme « un Statut est soit EnPreparation, soit Expediee avec un suivi, soit Livree avec une date, soit Annulee avec un motif ». Chaque ligne est un cas ; ce qui suit of est la donnée que ce cas porte, et seulement ce cas. Une commande expédiée sans suivi, ou en préparation avec un suivi, est tout simplement impossible à construire.
On construit une valeur en appelant le cas comme une fonction :
let s1 = EnPreparation
let s2 = Expediee "TRK-88"
let s3 = Livree(DateOnly(2026, 10, 20))
Et on la consomme avec match, que vous connaissez depuis le premier palier. Chaque motif nomme un cas et décompose sa donnée en une variable :
let decrireStatut (statut: Statut) : string =
match statut with
| EnPreparation -> "en préparation"
| Expediee suivi -> $"expédiée, suivi {suivi}"
// ... un motif par cas restant
Dans la branche Expediee suivi, la variable suivi existe et contient le numéro ; dans les autres branches, elle n'existe pas. Il n'y a pas de champ null à tester.
Exercice — compléter commandes.fsx
Créez commandes.fsx dans votre dossier de travail. Le type Commande est un record, comme au premier palier, dont l'un des champs est l'union :
// commandes.fsx — le domaine de l'assistant : du F# pur, sans package, sans modèle.
// Ce fichier ne fait que déclarer des types et des fonctions : il n'affiche rien,
// car il est chargé par d'autres scripts (dont le serveur MCP du module 09).
open System
// Module 03 — une union discriminée : un statut a une forme parmi quatre,
// et chaque forme porte exactement les données qui la concernent.
type Statut =
| EnPreparation
| Expediee of suivi: string
| Livree of date: DateOnly
| Annulee of motif: string
type Commande = { Id: int; Statut: Statut }
let commandes =
Map [
(4512, { Id = 4512; Statut = Expediee "TRK-88" })
(7781, { Id = 7781; Statut = EnPreparation })
(3020, { Id = 3020; Statut = Livree(DateOnly(2026, 10, 20)) })
(6034, { Id = 6034; Statut = Annulee "rupture de stock" })
]
let decrireStatut (statut: Statut) : string =
match statut with
| EnPreparation -> "en préparation"
| Expediee suivi -> $"expédiée, suivi {suivi}"
// À vous : un motif pour Livree, un motif pour Annulee
let decrire (commande: Commande) : string =
$"Commande {commande.Id} : {decrireStatut commande.Statut}"
Map [ (clé, valeur); ... ] construit un dictionnaire immuable trié par clé, l'équivalent F# d'un ImmutableSortedDictionary ; vous vous en servirez au module suivant pour chercher une commande par son numéro.
À vous de compléter decrireStatut pour que les deux cas restants produisent exactement ces textes :
- une commande livrée :
livrée le 2026-10-20(la date au formatyyyy-MM-dd) ; - une commande annulée :
annulée (rupture de stock).
Pour vérifier, créez un second script, statuts.fsx, qui charge le premier et affiche chaque commande :
// statuts.fsx — module 03 : afficher la description de chaque commande d'exemple.
#load "commandes.fsx"
open Commandes
for commande in Map.values commandes do
printfn "%s" (decrire commande)
for ... in ... do est le foreach de C#, et Map.values renvoie les valeurs du dictionnaire.
dotnet fsi statuts.fsx
Sortie attendue (les commandes sortent triées par numéro, car une Map est ordonnée par clé) :
Commande 3020 : livrée le 2026-10-20
Commande 4512 : expédiée, suivi TRK-88
Commande 6034 : annulée (rupture de stock)
Commande 7781 : en préparation
Si vous bloquez sur la date : un DateOnly se formate comme en C#, avec date.ToString("yyyy-MM-dd"). Dans une chaîne interpolée $"...", des guillemets à l'intérieur des accolades imposent d'ouvrir la chaîne avec trois guillemets : $"""livrée le {date.ToString("yyyy-MM-dd")}""".
Solution
let decrireStatut (statut: Statut) : string =
match statut with
| EnPreparation -> "en préparation"
| Expediee suivi -> $"expédiée, suivi {suivi}"
| Livree date -> $"""livrée le {date.ToString("yyyy-MM-dd")}"""
| Annulee motif -> $"annulée ({motif})"
Exercice — prédire puis vérifier : le cas oublié
Supprimez la ligne | Annulee motif -> ... de decrireStatut, sans rien changer d'autre.
Avant de relancer statuts.fsx, répondez par écrit à deux questions :
- Le compilateur va-t-il refuser le script, l'accepter en silence, ou l'accepter avec un message ?
- Si le script s'exécute, que se passera-t-il en arrivant à la commande 6034 ?
Puis relancez dotnet fsi statuts.fsx, et comparez avec vos réponses dans la section suivante.
Ce que vous devez observer
Le compilateur accepte le script, mais émet un avertissement de correspondance incomplète, FS0025, qui nomme le cas oublié. Le message tient sur une seule ligne ; il est coupé ici en deux pour la lisibilité, et le chemin du fichier est raccourci :
commandes.fsx(26,11): warning FS0025: Incomplete pattern matches on this expression.
For example, the value 'Annulee (_)' may indicate a case not covered by the pattern(s).
Puis l'exécution commence, affiche les deux premières commandes, et s'arrête sur la troisième :
Commande 3020 : livrée le 2026-10-20
Commande 4512 : expédiée, suivi TRK-88
Microsoft.FSharp.Core.MatchFailureException: The match cases were incomplete
(suivie de la pile d'appels et de Stopped due to error, raccourcies ici)
Le texte exact de l'avertissement peut varier selon la version du compilateur ; ce qui ne varie pas, c'est qu'il désigne le cas manquant avant l'exécution. Rétablissez la ligne Annulee : l'avertissement disparaît et la sortie redevient celle de l'exercice précédent.
Pourquoi ne pas ajouter | _ -> ... ?
Pour faire taire l'avertissement, on pourrait ajouter un joker | _ -> "statut inconnu" en dernière branche. Sur une union que vous possédez, c'est une mauvaise idée : le jour où vous ajouterez un cas — par exemple Retournee —, le joker l'avalera silencieusement, et le compilateur ne pourra plus vous montrer les match à mettre à jour. Sans joker, ajouter un cas fait apparaître un FS0025 à chaque endroit concerné : c'est une liste de travail gratuite. Vous en profiterez au module 8.
Ce que vous venez de faire
Vous avez décrit un statut de commande qui a quatre formes, dont trois portent des données de types différents (string, DateOnly), construit des valeurs de chaque cas, et écrit un match exhaustif qui décompose chaque cas. Vous avez vu le compilateur signaler un cas oublié avant l'exécution, et compris pourquoi un joker sur votre propre union désactive cette protection.
Au module suivant, vous écrivez la recherche d'une commande par son numéro. Que renvoyer quand le numéro n'existe pas ? En C#, ce serait null. En F#, ce sera une autre union discriminée — que vous connaissez déjà sans le savoir.
Vérifiez votre compréhension
Un script déclare `type Livraison = EnRelais of point: string | ADomicile of adresse: string | Retrait`, puis une fonction `libelle` dont le `match` ne traite que `EnRelais` et `ADomicile`. Le script se termine par `libelle Retrait`. Que se passe-t-il quand vous le lancez ?
Un colis peut être en attente, remis au transporteur (avec le nom du transporteur) ou perdu (avec la date de déclaration). Un collègue propose une `string` pour l'état, deux champs facultatifs, et un `| _ -> "inconnu"` dans chaque `match` « pour être tranquille ». Que lui répondez-vous ?
Envie d'être prévenu des prochains modules ?
L'Académie reste gratuite et en accès libre, sans inscription. Si vous voulez juste être averti par email à la sortie d'un nouveau module, c'est ici — aucune obligation, désinscription en un clic.