Skip to content
Learni
Voir tous les tutoriels
F#

Comment créer une CLI TODO en F# en 2026

Read in English

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

terminal
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

TodoCli/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 Exe 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

TodoCli/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 string

Les 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)

TodoCli/Commands.fs
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)

TodoCli/Commands.fs
/// É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)

TodoCli/Commands.fs
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

TodoCli/Program.fs
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

    0

Boucle 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

terminal
cd TodoCli
dotnet build
dotnet run
# Test:
# add Acheter du lait
# list
# complete 1
# quit

dotnet 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 Ref sauf 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 Async ou Task pour 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 state en 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.ReadAllText bloque la CLI ; toujours Async.

Pour aller plus loin