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:

  • Immutabilityadd_item doesn't modify the list; it returns a new one with the item appended (++).
  • Map over transformcomplete_item uses Enum.map to 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 selectionpending_items and remove_item both use Enum higher-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

  1. Represent state as data, not as objects with methods.
  2. Write pure functions that accept state, transform it, and return new state.
  3. Use Enum functions instead of loops — map, filter, reject, reduce.
  4. Use pattern matching for control flow — it's more explicit and exhaustive than conditionals.
  5. 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.