Skip to content
Learni
View all tutorials
F#

How to Build a TODO CLI in F# in 2026

Lire en français

Introduction

F# is a mature functional language on the .NET platform, perfect for robust CLI apps thanks to its native support for immutability, pattern matching, and discriminated unions (DU). In 2026, with .NET 8+, F# shines in command-line tools where conciseness and type safety are key. This intermediate tutorial walks you through creating a complete TODO CLI: add, list, complete tasks with asynchronous JSON persistence and functional error handling via Result.

Why this project? It showcases F#'s strengths: immutability by default (avoids concurrency bugs), exhaustive pattern matching (full case coverage), and functional composition for maintainable code. At the end, you'll have a professional, extensible app you'll bookmark for your .NET projects. Ready to code? (128 words)

Prerequisites

  • .NET SDK 8.0 or higher (download from dotnet.microsoft.com)
  • Editor: VS Code with the Ionide extension (F# support) for autocompletion and debugging
  • Basic knowledge of functional programming (lists, higher-order functions)
  • Terminal (PowerShell, Bash, or CMD)

Initialize the Project

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 .

These commands create an empty F# console project, add System.Text.Json for asynchronous serialization, and open VS Code. Fantomas ensures consistent formatting, crucial for F# readability. Avoid dotnet new without -lang F#, which defaults to C#.

Configure the Project File

Update the .fsproj file to target .NET 8 and enable development tools. This enables fast builds and smooth NuGet support.

Update 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>

This .fsproj enables implicit usings (auto open System), nullable reference types for safety, and preview LangVersion for 2026 features. Add packages without restarting. Pitfall: Forgetting Exe makes it a library instead of an app.

Define Domain Types

Use records for immutable data and discriminated unions (DU) to model CLI commands exhaustively. This ensures full coverage via pattern matching.

Create Types.fs

TodoCli/Types.fs
namespace TodoCli

/// Unique ID for a task (positive integer)
type TaskId = int

/// Task status
[<RequireQualifiedAccess>]
type TaskStatus =
    | Pending
    | Done

/// Immutable TODO task
[<Struct>]
type TodoTask = {
    Id: TaskId
    Description: string
    Status: TaskStatus
} with

    /// Create new auto-incremented ID
    static member Create(description: string) : TodoTask =
        { Id = 0; Description = description; Status = TaskStatus.Pending }

/// Supported CLI commands
type Command =
    | Add of description: string
    | List
    | Complete of TaskId
    | Quit
    | Unknown of string

Records are concise and support structural equality. DUs with [] avoid ambiguous names. The Create static method initializes without mutation. Pitfall: Without [], records allocate on the heap; here it's stack-allocated for performance.

Implement Command Parsing

Parse user input with pattern matching on keywords. Use Result to propagate errors without exceptions, in true functional style.

Create Commands.fs (Parsing)

TodoCli/Commands.fs
namespace TodoCli

open System
open Types

/// Safe type for CLI errors
type CliError = string

/// Parse a line into Command or error
let parseCommand (line: string) : Result<Command, CliError> =
    let words = line.Trim().Split([| ' ' |], StringSplitOptions.RemoveEmptyEntries)
    match Array.toList words with
    | [] -> Error "Empty command. Type 'help' for help."
    | "add" :: description ->
        if List.isEmpty description then
            Error "Missing description after '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 "Invalid ID after 'complete'"
    | "quit" -> Ok Quit
    | "help" -> printfn "Commands: add <desc>, list, complete <id>, quit"; Error "Help displayed"
    | _ -> Error (sprintf "Unknown command: %s" line)

Pattern matching covers all cases (compiler-enforced exhaustiveness). Result propagates errors without polluting try-catch. String.concat handles multi-word descriptions. Pitfall: Forgetting StringSplitOptions.RemoveEmptyEntries breaks parsing on multiple spaces.

Handle State and Operations

Store tasks in an immutable List. Implement CRUD with pure functions and pattern matching for updates.

Extend Commands.fs (Operations)

TodoCli/Commands.fs
/// Global state: task list with nextId
[<Struct>]
type TodoState = {
    Tasks: TodoTask list
    NextId: TaskId
}

let initialState: TodoState = { Tasks = []; NextId = 1 }

/// Add a task
let addTask state desc : TodoState =
    let task = { TodoTask.Create desc with Id = state.NextId }
    { state with Tasks = task :: state.Tasks; NextId = state.NextId + 1 }

/// List formatted tasks
let listTasks (state: TodoState) : unit =
    if List.isEmpty state.Tasks then
        printfn "No tasks."
    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)

/// Complete a task by ID
let completeTask state id : Result<TodoState, CliError> =
    match state.Tasks |> List.tryFind (fun t -> t.Id = id) with
    | None -> Error (sprintf "Task %d not found" 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 }

State is an immutable record; functions return new states (no mutation). List.tryFind + None|Some pattern is idiomatic. Sort by descending ID for recent-first display. Pitfall: List.map without tryFind iterates unnecessarily; filter first.

Add Asynchronous JSON Persistence

Save/load state to JSON with System.Text.Json and async. This demonstrates async/await composition in F#.

Complete Commands.fs (Persistence)

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 for non-blocking I/O; do! awaits like in C#. Default JsonSerializerOptions() handles records/DUs (native F# JSON support). Option.defaultValue provides safe fallback. Pitfall: Without Async, the app blocks on I/O; always use async for responsive CLIs.

Assemble in Program.fs

Entry point: REPL loop with parseCommand, processCommand, and async save. Pattern matching on Result for errors.

Create Complete Program.fs

TodoCli/Program.fs
namespace TodoCli

open System
open Commands

[<EntryPoint>]
let main argv =
    task {
        let! state = loadStateAsync()
        let mutable currentState = state

        printfn "=== F# TODO CLI ==="
        printfn "Type 'help' for help."

        while true do
            printf "todo> "
            let line = Console.ReadLine()
            if isNull line then () else
                match parseCommand line with
                | Error msg -> printfn "Error: %s" msg
                | Ok cmd ->
                    match cmd with
                    | Quit -> do! saveStateAsync currentState; printfn "Goodbye!"; 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 "Error: %s" msg

        return 0
    } |> Async.StartAsTask |> ignore

    0

while true loop with local mutable (acceptable in entry point). task { } for top-level async; match on Result and Command. Auto-save after changes. Pitfall: Global mutable is an anti-pattern; keep it confined. Async.StartAsTask for sync main.

Build and Test

terminal
cd TodoCli
dotnet build
dotnet run
# Test:
# add Buy milk
# list
# complete 1
# quit

dotnet build compiles without running; run launches the CLI. Persistence in todo.json. Fantomas: dotnet fantomas . to format. Pitfall: Forgetting cd TodoCli fails the run.

Best Practices

  • Immutability: Always return new states; avoid Ref except for critical perf.
  • Exhaustive Pattern Matching: Let the compiler check coverage (warnings as errors).
  • Result/Choice: Prefer over exceptions for business errors; compose with Result.bind.
  • Async for All I/O: Use Async or Task for non-blocking.
  • Records/DU: Model your domain; add with members for methods.
  • Format with Fantomas: Integrate into CI for team consistency.

Common Errors to Avoid

  • Global Mutation: Don't use let mutable state in wide scopes; pass as parameters.
  • Incomplete Patterns: Add | _ -> Error ...; enable -warnaserror.
  • JSON Without Options: DUs won't serialize without config; test roundtrip.
  • Sync I/O in Loops: File.ReadAllText blocks the CLI; always use Async.

Next Steps