Map, Filter, Reduce: The Enum Module is Your New Best Friend

Coming from other languages, you might be used to writing loops. Lots of loops. for loops, while loops, forEach. They’re everywhere.

Elixir has a different philosophy. Instead of telling the computer how to iterate, you describe what you want to happen to each element. The Enum module is where this magic lives, and mastering it will fundamentally change how you think about data processing.

Today we’re building the core of our game: herb pricing, game state, and the actions that make HerbWars playable. By the end, you’ll be buying and selling herbs from IEx.

Thinking in Transformations

Let’s try this in IEx. Say you have a list of numbers and want each one doubled. Elixir has a for comprehension that looks like a traditional loop:

iex> for n <- [1, 2, 3], do: n * 2
[2, 4, 6]

That works, but Elixir developers rarely reach for for. The idiomatic way is Enum.map:

iex> Enum.map([1, 2, 3], fn number -> number * 2 end)
[2, 4, 6]

Same result, but Enum.map composes better with pipelines (which you’ll see shortly) and makes the intent clearer: “transform each element with this function.” The for comprehension shines for more complex scenarios like filtering and nested iteration — we’ll revisit it later. For now, Enum.map is your go-to.

Anonymous Functions: Three Ways

You’ll write a lot of anonymous functions with Enum. Try all three syntaxes in IEx. First, set up some data:

iex> prices = [10, 20, 30]
[10, 20, 30]

Full syntax — the most explicit:

iex> Enum.map(prices, fn price -> price * 2 end)
[20, 40, 60]

Capture syntax — concise when the function is a single expression:

iex> Enum.map(prices, &(&1 * 2))
[20, 40, 60]

The &(&1 * 2) looks weird at first. &1 is the first argument, &2 would be the second, and so on. You’ll get used to it quickly.

Named function reference — when you already have a function defined:

iex> double = fn n -> n * 2 end
iex> Enum.map(prices, double)
[20, 40, 60]

All three produce the same result. Use full syntax when the logic is complex, capture syntax for one-liners, and named references when the function already exists.

Beyond Map: Filter and Reduce

Enum.map transforms every element, but sometimes you only want some elements or need to combine them into a single value.

Filter keeps elements that pass a test:

iex> Enum.filter([1, 2, 3, 4, 5, 6], fn n -> rem(n, 2) == 0 end)
[2, 4, 6]

Reduce accumulates elements into a single result:

iex> Enum.reduce([1, 2, 3, 4], 0, fn number, total -> total + number end)
10

The three arguments: the collection, the starting accumulator (0), and a function that takes (current element, accumulator) and returns the new accumulator. Walking through: 0 + 1 = 1, then 1 + 2 = 3, then 3 + 3 = 6, then 6 + 4 = 10.

Find returns the first match:

iex> Enum.find([3, 7, 2, 9], fn n -> n > 5 end)
7

You’ll see all three patterns in the modules we’re about to build.

Common Enum Patterns

Here’s a reference you’ll use constantly:

I want to…Use…
Transform each elementEnum.map/2
Keep elements matching conditionEnum.filter/2
Remove elements matching conditionEnum.reject/2
Find one elementEnum.find/2
Check if any matchEnum.any?/2
Check if all matchEnum.all?/2
Accumulate a resultEnum.reduce/3
Get first NEnum.take/2
Sort by somethingEnum.sort_by/2
Count matchesEnum.count/2
Sum valuesEnum.sum/1

Building the Herb Module: Test First

Now let’s put Enum to work. We need a module that manages herb data and market pricing. Before writing any implementation, describe the behavior we expect. Create test/herb_wars/herb_test.exs:

defmodule HerbWars.HerbTest do
  use ExUnit.Case

  alias HerbWars.Herb

  test "all_herbs returns herb names as strings" do
    herbs = Herb.all_herbs()

    assert is_list(herbs)
    assert "Mint" in herbs
    assert "Stinging Nettles" in herbs
  end

  test "get_price returns price for a herb" do
    prices = %{"Mint" => 20, "Dandelion" => 35}

    assert Herb.get_price(prices, "Mint") == 20
    assert Herb.get_price(prices, "Unknown") == nil
  end

  test "market_summary returns cheapest, most expensive, and average" do
    prices = %{"Mint" => 20, "Dandelion" => 40}
    summary = Herb.market_summary(prices)

    assert summary.cheapest == {"Mint", 20}
    assert summary.expensive == {"Dandelion", 40}
    assert summary.average == 30
  end

  test "generate_city_prices uses herb volatility for pricing" do
    prices = Herb.generate_city_prices("Akron", fn -> 0.5 end)

    assert prices["Mint"] == 17
    assert prices["Stinging Nettles"] == 75
  end
end

That last test looks odd — we’re passing fn -> 0.5 end as a second argument to generate_city_prices. What’s going on?

The problem: generate_city_prices calls :rand.uniform(), which returns a different number every time. That makes it impossible to test — we can’t assert exact prices when the output is random. We could skip testing it entirely, but that leaves a gap.

Instead, we’ll use dependency injection: instead of hardcoding the random function inside generate_city_prices, we’ll accept it as a parameter. In production, callers won’t pass it and the function defaults to :rand.uniform/0 — real randomness. In tests, we inject fn -> 0.5 end — a function that always returns 0.5 — so the output is predictable.

With fn -> 0.5 end, we can trace the math by hand. The random function gets called twice per herb: once for variance, once for the multiplier direction. For Mint (base price 20, volatility 0.3):

  1. First call: 0.5 * 0.3 = 0.15 (variance)
  2. Second call: 0.5 > 0.5 is false, so the multiplier is 1 - 0.15 = 0.85
  3. Final price: round(20 * 0.85) = 17

That’s 17 — exactly what our test asserts. By injecting a fixed random function, we turned an untestable function into one where we can verify the exact output.

Run mix test — four failures. The HerbWars.Herb module doesn’t exist yet. Now create lib/herb_wars/herb.ex:

defmodule HerbWars.Herb do
  @moduledoc """
  Manages herb data and market pricing.
  """

  @herbs [
    %{name: "Mint", base_price: 20, volatility: 0.3},
    %{name: "Crushed Dandelion", base_price: 35, volatility: 0.5},
    %{name: "Wild Mushrooms", base_price: 80, volatility: 0.8},
    %{name: "Stinging Nettles", base_price: 150, volatility: 1.0}
  ]

  def all_herbs, do: Enum.map(@herbs, & &1.name)

  def generate_city_prices(_city, rand_fn \\ &:rand.uniform/0) do
    @herbs
    |> Enum.map(fn herb ->
      variance = rand_fn.() * herb.volatility
      multiplier = if rand_fn.() > 0.5, do: 1 + variance, else: 1 - variance
      {herb.name, max(1, round(herb.base_price * multiplier))}
    end)
    |> Enum.into(%{})
  end

  def get_price(prices, herb_name), do: Map.get(prices, herb_name)

  def market_summary(prices) do
    {min_herb, min_price} = Enum.min_by(prices, fn {_, p} -> p end)
    {max_herb, max_price} = Enum.max_by(prices, fn {_, p} -> p end)
    avg = round(Enum.sum(Map.values(prices)) / map_size(prices))

    %{
      cheapest: {min_herb, min_price},
      expensive: {max_herb, max_price},
      average: avg
    }
  end
end

Let’s walk through each function:

@herbs is a module attribute — a compile-time constant that holds our herb definitions. Each herb has a name, base price, and volatility (how much the price swings).

all_herbs/0 uses Enum.map to extract just the names from the herb maps. The & &1.name is capture syntax for fn herb -> herb.name end.

generate_city_prices/2 creates randomized market prices. The second argument, rand_fn, defaults to &:rand.uniform/0 — Erlang’s built-in random number generator. In production, callers don’t pass it and get real randomness. In tests, we inject fn -> 0.5 end to get predictable output. This pattern is called dependency injection: instead of hardcoding a dependency (the random function), we accept it as a parameter. The function stays pure when we choose to make it pure, and stays convenient when we don’t.

It maps each herb to a {name, price} tuple with a random multiplier based on volatility, then Enum.into(%{}) converts the list of tuples into a map like %{"Mint" => 18, "Crushed Dandelion" => 42}.

get_price/2 is a thin wrapper around Map.get — it returns the price for a herb or nil if the herb isn’t in the price list.

market_summary/1 demonstrates several Enum functions working together: Enum.min_by and Enum.max_by find the cheapest and most expensive herbs, and Enum.sum with Map.values calculates the average.

Run mix test — 1 doctest, 16 tests, 0 failures.

The GameState Struct: Test First

Our game needs to track more than just the player — we need the current city, market prices, and time remaining. Start with the tests. Create test/herb_wars/game_state_test.exs:

defmodule HerbWars.GameStateTest do
  use ExUnit.Case

  alias HerbWars.{GameState, Player}

  test "has sensible defaults" do
    game = %GameState{}

    assert game.current_city == "Akron"
    assert game.days_remaining == 30
    assert game.market_prices == %{}
  end

  test "composes with Player struct" do
    game = %GameState{player: Player.new()}

    assert game.player.money == 1000
    assert game.player.inventory == %{}
  end
end

Run mix test — two failures. Now create lib/herb_wars/game_state.ex:

defmodule HerbWars.GameState do
  @moduledoc """
  Represents the complete state of a game.
  """

  alias HerbWars.Player

  @cities ["Akron", "Buffalo", "Cleveland", "Detroit", "Erie", "Flint"]

  defstruct player: nil,
            current_city: "Akron",
            market_prices: %{},
            days_remaining: 30

  @type t :: %__MODULE__{
          player: Player.t(),
          current_city: String.t(),
          market_prices: %{String.t() => integer()},
          days_remaining: integer()
        }

  def all_cities, do: @cities
end

This struct holds everything you’d need to save, load, or reason about a game. The player field holds a Player struct from ch2, demonstrating how structs compose. @cities is a module attribute listing the game’s locations.

Run mix test — 1 doctest, 18 tests, 0 failures.

Pure Game Actions: Test First

Now the fun part. Let’s build functions that actually play the game — buy herbs, sell herbs, travel between cities. The key insight: these functions are pure. They take a game state, return a new game state (or an error). No IO, no side effects, no user prompts. Just data in, data out.

We’ll follow the same rhythm as ch2: write a test, watch it fail, make it pass.

Buying Herbs

Open test/herb_wars_test.exs and replace its contents with our first game action tests:

defmodule HerbWarsTest do
  use ExUnit.Case

  alias HerbWars.{GameState, Player}

  defp new_test_game do
    %GameState{
      player: Player.new(),
      current_city: "Akron",
      market_prices: %{"Mint" => 20, "Crushed Dandelion" => 35},
      days_remaining: 30
    }
  end

  describe "buy/3" do
    test "buys herbs and deducts money" do
      game = new_test_game()

      {:ok, game} = HerbWars.buy(game, "Mint", 3)

      assert game.player.money == 940
      assert game.player.inventory == %{"Mint" => 3}
    end

    test "fails for unknown herb" do
      game = new_test_game()

      assert {:error, "Unknown herb"} = HerbWars.buy(game, "Fake Herb", 1)
    end

    test "fails when player can't afford it" do
      game = new_test_game()

      assert {:error, "Not enough money"} = HerbWars.buy(game, "Mint", 100)
    end
  end
end

new_test_game/0 is a private helper that builds a GameState with fixed prices instead of random ones — this gives us deterministic tests. We’ll reuse it for every game action test.

Run mix test — three failures. Now open lib/herb_wars.ex. Right now this module just has a @moduledoc from ch1 — let’s fill it with the first game functions:

defmodule HerbWars do
  @moduledoc """
  Core game logic for HerbWars.
  """

  alias HerbWars.{GameState, Player, Herb}

  def new_game do
    %GameState{
      player: Player.new(),
      current_city: "Akron",
      market_prices: Herb.generate_city_prices("Akron"),
      days_remaining: 30
    }
  end

  def buy(game, herb_name, quantity) do
    with {:ok, price} <- get_herb_price(game, herb_name),
         {:ok, player} <- Player.spend_money(game.player, price * quantity),
         {:ok, player} <- Player.add_herb(player, herb_name, quantity) do
      {:ok, %{game | player: player}}
    end
  end

  defp get_herb_price(game, herb_name) do
    case Herb.get_price(game.market_prices, herb_name) do
      nil -> {:error, "Unknown herb"}
      price -> {:ok, price}
    end
  end
end

new_game/0 creates a fresh game: new player, starting city, randomized market prices, and 30 days on the clock.

get_herb_price/2 is a private helper (defp) that wraps Herb.get_price in the {:ok, price} / {:error, reason} tuple pattern. It’s private because only the functions in this module need it.

buy/3 introduces with, one of Elixir’s most elegant constructs. with chains operations that might fail. Each line uses <- to pattern-match the result:

  1. Look up the herb’s price — fails if the herb doesn’t exist
  2. Spend the money — fails if the player can’t afford it
  3. Add the herb to inventory — fails if inventory is full

If any step returns something other than {:ok, value}, the with immediately returns that non-matching value. Since all our error functions already return {:error, reason} tuples, those errors pass straight through without any explicit error handling. No nested case statements needed.

Run mix test — 1 doctest, 20 tests, 0 failures.

Selling Herbs

Add the sell tests to test/herb_wars_test.exs, inside the module after the buy describe block:

  describe "sell/3" do
    test "sells herbs and adds money" do
      game = new_test_game()
      {:ok, game} = HerbWars.buy(game, "Mint", 5)

      {:ok, game} = HerbWars.sell(game, "Mint", 3)

      assert game.player.money == 960
      assert game.player.inventory == %{"Mint" => 2}
    end

    test "fails when not enough herbs" do
      game = new_test_game()

      assert {:error, _} = HerbWars.sell(game, "Mint", 1)
    end
  end

The sell test calls buy first to set up inventory — reusing our existing game logic to build the state we need.

Run mix test — two failures. Add sell/3 to lib/herb_wars.ex, above the private get_herb_price function:

  def sell(game, herb_name, quantity) do
    with {:ok, price} <- get_herb_price(game, herb_name),
         {:ok, player} <- Player.remove_herb(game.player, herb_name, quantity) do
      player = Player.add_money(player, price * quantity)
      {:ok, %{game | player: player}}
    end
  end

Selling mirrors buying: look up the price, remove from inventory, add the money. Notice Player.remove_herb/3 from ch2 is finally being used in game logic — this is where it fits in the flow.

Run mix test — 1 doctest, 22 tests, 0 failures.

Traveling Between Cities

Add the travel tests to test/herb_wars_test.exs:

  describe "travel/2" do
    test "travels to a valid city" do
      game = new_test_game()

      {:ok, game} = HerbWars.travel(game, "Detroit")

      assert game.current_city == "Detroit"
      assert game.days_remaining == 29
      assert game.market_prices != %{}
    end

    test "fails for unknown city" do
      game = new_test_game()

      assert {:error, "Unknown city"} = HerbWars.travel(game, "Atlantis")
    end

    test "fails for current city" do
      game = new_test_game()

      assert {:error, "Already in Akron"} = HerbWars.travel(game, "Akron")
    end
  end

Run mix test — three failures. Add travel/2 to lib/herb_wars.ex:

  def travel(game, destination) do
    cond do
      destination not in GameState.all_cities() ->
        {:error, "Unknown city"}

      destination == game.current_city ->
        {:error, "Already in #{destination}"}

      true ->
        {:ok, %{game |
          current_city: destination,
          market_prices: Herb.generate_city_prices(destination),
          days_remaining: game.days_remaining - 1
        }}
    end
  end

Travel validates the destination with cond, which checks conditions in order. If the destination is valid and different from the current city, we generate new market prices and decrement the days remaining.

Run mix test — 1 doctest, 25 tests, 0 failures.

..........................

Finished in 0.0x seconds
1 doctest, 25 tests, 0 failures

26 tests total — 1 doctest from the Player module, 11 explicit Player tests from ch2, plus 14 new tests: 4 for the Herb module (including generate_city_prices with dependency injection), 2 for GameState, and 8 for game actions. Every function is tested.

Playing from IEx

The game actions are pure functions, which means you can play directly from IEx:

iex -S mix
iex> game = HerbWars.new_game()
iex> game.current_city
"Akron"

iex> game.market_prices
%{"Mint" => 18, "Crushed Dandelion" => 42, ...}

iex> {:ok, game} = HerbWars.buy(game, "Mint", 5)
iex> game.player.money
910

iex> game.player.inventory
%{"Mint" => 5}

iex> {:ok, game} = HerbWars.travel(game, "Detroit")
iex> game.market_prices   # New prices!
%{"Mint" => 25, ...}

iex> {:ok, game} = HerbWars.sell(game, "Mint", 5)
iex> game.player.money    # Profit!
1035

iex> HerbWars.sell(game, "Mint", 1)
{:error, "Not enough Mint in inventory"}

No UI, no game loop — just data transformations. You can inspect the game state at any point, rewind by keeping old bindings, or script entire sessions. This is the power of pure functions.

References

What’s Next?

The game works from IEx, but players deserve a real interface. In the next chapter, we’ll build a UI module that displays game status, reads player input, and connects user commands to the pure game functions we just wrote. The core logic won’t change — we’re just layering presentation on top.