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
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
<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 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
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 stringRecords 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)
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)
/// 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)
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
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
0while 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
cd TodoCli
dotnet build
dotnet run
# Test:
# add Buy milk
# list
# complete 1
# quitdotnet 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
Refexcept 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
AsyncorTaskfor 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 statein 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.ReadAllTextblocks the CLI; always useAsync.
Next Steps
- Read the official F# docs and F# for Fun and Profit.
- Add Argu for advanced arg parsing:
dotnet add package Argu. - Upgrade to Giraffe for a web API in F#.
- Check out our Learni trainings on .NET and F# for expert level.