Introduction
F# est un langage fonctionnel mature sur la plateforme .NET, idéal pour des applications CLI robustes grâce à son support natif des paradigmes immutables, du pattern matching et des types discriminés (DU). En 2026, avec .NET 8+, F# excelle dans les outils en ligne de commande où la concision et la sécurité des types priment. Ce tutoriel intermediate vous guide pour créer une CLI TODO complète : ajout, liste, complétion de tâches avec persistance JSON asynchrone et gestion d'erreurs fonctionnelle via Result.
Pourquoi ce projet ? Il illustre les forces de F# : immutabilité par défaut (évite les bugs de concurrence), pattern matching exhaustif (couverture totale des cas), et composition fonctionnelle pour un code maintenable. À la fin, vous aurez une app professionnelle, extensible, que vous bookmarkeriez pour vos projets .NET. Prêt à coder ? (128 mots)
Prérequis
- .NET SDK 8.0 ou supérieur (téléchargeable sur dotnet.microsoft.com)
- Éditeur : VS Code avec l'extension Ionide (F# support) pour autocomplétion et debugging
- Connaissances de base en programmation fonctionnelle (listes, fonctions de haut niveau)
- Terminal (PowerShell, Bash ou CMD)
Initialiser le projet
dotnet new console -lang F# -o TodoCli
cd TodoCli
dotnet tool install --global dotnet-fantomas # Outil de formatage F# (optionnel mais recommandé)
dotnet add package System.Text.Json --version 8.0.5
code .Cette commande crée un projet console F# vide, ajoute System.Text.Json pour la sérialisation asynchrone, et ouvre VS Code. Fantomas assure un formatage cohérent, essentiel pour la lisibilité en F#. Évitez dotnet new sans -lang F# qui génère du C# par défaut.
Configurer le fichier projet
Modifiez le fichier .fsproj pour cibler .NET 8 et activer les outils de développement. Cela permet des builds rapides et un support NuGet fluide.
Mettre à jour TodoCli.fsproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Text.Json" Version="8.0.5" />
</ItemGroup>
</Project>Ce .fsproj active les implicit usings (open System auto), nullable reference types pour la sécurité, et LangVersion preview pour les features 2026. Ajoutez-y vos packages sans redémarrer. Piège : Oublier rend l'app une lib.
Définir les types métier
Utilisez des records pour les données immutables et des discriminated unions (DU) pour modéliser les commandes CLI de façon exhaustive. Cela garantit une couverture totale via pattern matching.
Créer Types.fs
namespace TodoCli
/// ID unique d'une tâche (entier positif)
type TaskId = int
/// Statut d'une tâche
[<RequireQualifiedAccess>]
type TaskStatus =
| Pending
| Done
/// Tâche TODO immutable
[<Struct>]
type TodoTask = {
Id: TaskId
Description: string
Status: TaskStatus
} with
/// Nouveau ID auto-incrémenté
static member Create(description: string) : TodoTask =
{ Id = 0; Description = description; Status = TaskStatus.Pending }
/// Commandes CLI supportées
type Command =
| Add of description: string
| List
| Complete of TaskId
| Quit
| Unknown of stringLes records sont concis et supportent l'égalité structurale. Les DU avec [ évitent les noms ambigus. La méthode statique Create initialise sans mutation. Piège : Sans [, les records allouent sur heap ; ici, c'est stack pour perf.
Implémenter le parsing des commandes
Parsez les entrées utilisateur avec pattern matching sur les mots-clés. Utilisez Result pour propager les erreurs sans exceptions, style fonctionnel.
Créer Commands.fs (parsing)
namespace TodoCli
open System
open Types
/// Type sûr pour les erreurs CLI
type CliError = string
/// Parse une ligne en Command ou erreur
let parseCommand (line: string) : Result<Command, CliError> =
let words = line.Trim().Split([| ' ' |], StringSplitOptions.RemoveEmptyEntries)
match Array.toList words with
| [] -> Error "Commande vide. Tapez 'help' pour l'aide."
| "add" :: description ->
if List.isEmpty description then
Error "Description manquante après 'add'"
else
Ok (Add (String.concat " " description))
| "list" -> Ok List
| "complete" :: idStr :: _ ->
match Int32.TryParse idStr with
| true, id when id > 0 -> Ok (Complete id)
| _ -> Error "ID invalide après 'complete'"
| "quit" -> Ok Quit
| "help" -> printfn "Commandes: add <desc>, list, complete <id>, quit"; Error "Aide affichée"
| _ -> Error (sprintf "Commande inconnue: %s" line)Le pattern matching couvre tous les cas (exhaustivité via compiler). Result propage les erreurs sans try-catch polluants. String.concat gère les descriptions multi-mots. Piège : Oublier StringSplitOptions.RemoveEmptyEntries casse le parsing des espaces multiples.
Gérer l'état et les opérations
Stockez les tâches en mémoire (immutable List). Implémentez CRUD avec fonctions pures et pattern matching pour les updates.
Étendre Commands.fs (opérations)
/// État global : liste de tâches avec nextId
[<Struct>]
type TodoState = {
Tasks: TodoTask list
NextId: TaskId
}
let initialState: TodoState = { Tasks = []; NextId = 1 }
/// Ajoute une tâche
let addTask state desc : TodoState =
let task = { TodoTask.Create desc with Id = state.NextId }
{ state with Tasks = task :: state.Tasks; NextId = state.NextId + 1 }
/// Liste les tâches formatées
let listTasks (state: TodoState) : unit =
if List.isEmpty state.Tasks then
printfn "Aucune tâche."
else
state.Tasks
|> List.sortByDescending (fun t -> t.Id)
|> List.iteri (fun i t ->
let status = if t.Status = TaskStatus.Done then "[✓]" else "[ ]"
printfn " %d: %s %s" t.Id status t.Description)
/// Complète une tâche par ID
let completeTask state id : Result<TodoState, CliError> =
match state.Tasks |> List.tryFind (fun t -> t.Id = id) with
| None -> Error (sprintf "Tâche %d introuvable" id)
| Some task ->
let updated = { task with Status = TaskStatus.Done }
let newTasks = state.Tasks |> List.map (fun t -> if t.Id = id then updated else t)
Ok { state with Tasks = newTasks }L'état est un record immutable ; les fonctions retournent un nouvel état (pas de mutation). List.tryFind + pattern None|Some est idiomatique. Tri par ID descendant pour affichage récent en haut. Piège : List.map sans tryFind itère inutilement ; filtrez d'abord.
Ajouter persistance JSON asynchrone
Sauvegardez/chargez l'état en JSON avec System.Text.Json et async. Cela démontre la composition async/await en F#.
Compléter Commands.fs (persistance)
open System.Text.Json
open System.IO
let dataFile = "todo.json"
let saveStateAsync (state: TodoState) : Async<unit> =
async {
let json = JsonSerializer.Serialize(state, JsonSerializerOptions())
do! File.WriteAllTextAsync(dataFile, json)
}
let loadStateAsync () : Async<TodoState> =
async {
if File.Exists dataFile then
let! json = File.ReadAllTextAsync(dataFile)
return JsonSerializer.Deserialize<TodoState>(json, JsonSerializerOptions()) |> Option.defaultValue initialState
else
return initialState
}Async pour I/O non-bloquant ; do! await comme en C#. JsonSerializerOptions() par défaut gère records/DU (F# JSON support natif). Option.defaultValue fallback safe. Piège : Sans Async, l'app bloque sur I/O ; toujours async pour CLI responsive.
Assembler dans Program.fs
Point d'entrée : Boucle REPL avec parseCommand, processCommand et async save. Pattern matching sur Result pour erreurs.
Créer Program.fs complet
namespace TodoCli
open System
open Commands
[<EntryPoint>]
let main argv =
task {
let! state = loadStateAsync()
let mutable currentState = state
printfn "=== CLI TODO F# ==="
printfn "Tapez 'help' pour l'aide."
while true do
printf "todo> "
let line = Console.ReadLine()
if isNull line then () else
match parseCommand line with
| Error msg -> printfn "Erreur: %s" msg
| Ok cmd ->
match cmd with
| Quit -> do! saveStateAsync currentState; printfn "Au revoir!"; return 0
| List -> listTasks currentState
| Add desc -> currentState <- addTask currentState desc; do! saveStateAsync currentState
| Complete id ->
match completeTask currentState id with
| Ok newState -> currentState <- newState; do! saveStateAsync currentState
| Error msg -> printfn "Erreur: %s" msg
return 0
} |> Async.StartAsTask |> ignore
0Boucle while true avec mutable local (acceptable en entrée). task { } pour top-level async ; match sur Result et Command. Save auto après changements. Piège : mutable global est anti-pattern ; confinez-le. Async.StartAsTask pour main sync.
Build et test
cd TodoCli
dotnet build
dotnet run
# Test:
# add Acheter du lait
# list
# complete 1
# quitdotnet build compile sans exécution ; run lance la CLI. Persistance dans todo.json. Fantomas: dotnet fantomas . pour formater. Piège : Oublier cd TodoCli échoue le run.
Bonnes pratiques
- Immutabilité : Toujours retourner de nouveaux états ; évitez
Refsauf perfs critiques. - Pattern matching exhaustif : Laissez le compilateur vérifier la couverture (warnings as errors).
- Result/Choice : Préférez aux exceptions pour erreurs métier ; composez avec
Result.bind. - Async partout pour I/O : Utilisez
AsyncouTaskpour non-bloquant. - Records/DU : Modélisez le domaine ; ajoutez with members pour méthodes.
- Formattez avec Fantomas : Intégrez dans CI pour cohérence d'équipe.
Erreurs courantes à éviter
- Mutation globale : Ne pas utiliser
let mutable stateen scope large ; passez en paramètre. - Pattern incomplet : Ajoutez
| _ -> Error ...; activez-warnaserror. - JSON sans options : DU ne sérialisent pas sans config ; testez roundtrip.
- Sync I/O en boucle :
File.ReadAllTextbloque la CLI ; toujoursAsync.
Pour aller plus loin
- Lisez la doc officielle F# et F# for fun and profit.
- Ajoutez Argu pour parsing args avancés :
dotnet add package Argu. - Passez à Giraffe pour une API web F#.
- Découvrez nos formations Learni sur .NET et F# pour un niveau expert.