Why Build a Todo App in Elixir?
A command-line todo application is small enough to finish in an afternoon but rich enough to touch the concepts that matter: immutable state, pattern matching, the pipe operator, and recursive data transformation. Building it in Elixir gives you a practical introduction to functional programming without drowning in theory.
Project Setup
Create a new Mix project (Elixir's build tool):
mix new todo_cli
cd todo_cli
Your main module will live in lib/todo_cli.ex. Let's build from the ground up.
Modeling State with Plain Data
In Elixir, application state is just data — typically a map or a list of maps. A todo item can be represented as:
%{id: 1, title: "Learn Elixir", done: false}
Our entire todo list is a list of such maps. No classes, no objects, no mutation — just a list that we pass through functions and get a new list back.
Core Functions
Here are the key operations as pure functions:
defmodule TodoCli do
def new_list(), do: []
def add_item(list, title) do
id = length(list) + 1
list ++ [%{id: id, title: title, done: false}]
end
def complete_item(list, id) do
Enum.map(list, fn item ->
if item.id == id, do: %{item | done: true}, else: item
end)
end
def remove_item(list, id) do
Enum.reject(list, fn item -> item.id == id end)
end
def pending_items(list) do
Enum.filter(list, fn item -> not item.done end)
end
end
Understanding the Patterns
Several functional patterns are on display here:
- Immutability —
add_itemdoesn't modify the list; it returns a new one with the item appended (++). - Map over transform —
complete_itemusesEnum.mapto return a new list where only the target item is changed. The map update syntax%{item | done: true}creates a new map, not a mutation. - Filter for selection —
pending_itemsandremove_itemboth useEnumhigher-order functions instead of loops.
Adding a CLI Interface with Pattern Matching
Elixir's pattern matching makes parsing commands clean and explicit:
def handle_command(list, command) do
case command do
{:add, title} -> add_item(list, title)
{:done, id} -> complete_item(list, id)
{:remove, id} -> remove_item(list, id)
{:list, :all} -> (display(list); list)
{:list, :pending}-> (display(pending_items(list)); list)
_ -> (IO.puts("Unknown command"); list)
end
end
Each clause matches a specific command tuple. The catch-all _ handles invalid input gracefully. No if/else chains, no switch statements.
The Pipe Operator in Action
Elixir's |> operator threads the result of one expression into the first argument of the next. Here's a pipeline that adds items, completes one, and shows the pending list:
new_list()
|> add_item("Buy groceries")
|> add_item("Read SICP")
|> add_item("Write tests")
|> complete_item(1)
|> pending_items()
|> display()
Reading this top-to-bottom tells you exactly what's happening at each step. The data flows forward; nothing is mutated.
Key Takeaways
- Represent state as data, not as objects with methods.
- Write pure functions that accept state, transform it, and return new state.
- Use
Enumfunctions instead of loops —map,filter,reject,reduce. - Use pattern matching for control flow — it's more explicit and exhaustive than conditionals.
- Pipe everything — the
|>operator keeps transformation pipelines readable.
This small project demonstrates that functional programming isn't an abstract academic exercise — it produces clean, readable, and testable code for real-world tasks. From here, you can extend the app to persist todos to a file, add timestamps, or build a web interface with Phoenix.