Post

Starting Elixir - Part 2: Enum, Exercises & Processes

Starting Elixir - Part 2: Enum, Exercises & Processes

In Part 1 we covered the language fundamentals: pattern matching, immutability, the pipe operator, functions, and recursion. Now let’s put those tools to work and tackle the topics that make Elixir worth learning in the first place — ergonomic data processing with Enum, and concurrency with processes and tasks.

Enum — The Workhorse

In Part 1 we wrote recursive functions to work with lists. In practice, you rarely need to do that because the Enum module covers almost everything.

map — transform every element:

1
2
3
4
5
6
7
8
numbers = [8, 3, 15, 6, 21, 14]

Enum.map(numbers, fn x -> x * 2 end)
# => [16, 6, 30, 12, 42, 28]

# shorthand with the capture operator
Enum.map(numbers, &(&1 * 2))
# => [16, 6, 30, 12, 42, 28]

filter — keep elements that match a condition:

1
2
Enum.filter(numbers, fn x -> rem(x, 3) == 0 end)
# => [3, 15, 6, 21]

reduce — fold a list into a single value:

1
2
3
4
5
6
Enum.reduce(numbers, 0, fn x, acc -> acc + x end)
# => 67

# shorthand for operators
Enum.reduce(numbers, 0, &+/2)
# => 67

frequencies — count occurrences:

1
2
Enum.frequencies(["a", "b", "a", "c", "b", "a"])
# => %{"a" => 3, "b" => 2, "c" => 1}

group_by — group elements by a key function:

1
2
3
4
words = ["apple", "banana", "avocado", "blueberry", "apricot"]

Enum.group_by(words, &String.first/1)
# => %{"a" => ["apple", "avocado", "apricot"], "b" => ["banana", "blueberry"]}

Chaining them together with the pipe operator:

1
2
3
4
5
[8, 3, 15, 6, 21, 14]
|> Enum.filter(&(rem(&1, 3) == 0))
|> Enum.map(&(&1 * 10))
|> Enum.reduce(0, &+/2)
# => 450

Exercises

Let’s solve a few classic problems with what we know.

FizzBuzz

Print numbers 1 to 100, but replace multiples of 3 with "Fizz", multiples of 5 with "Buzz", and multiples of both with "FizzBuzz". Using anonymous function pattern matching inside Enum.map:

1
2
3
4
5
6
7
Enum.map(1..15, fn
  x when rem(x, 15) == 0 -> "FizzBuzz"
  x when rem(x, 3) == 0  -> "Fizz"
  x when rem(x, 5) == 0  -> "Buzz"
  x                      -> x
end)
# => [1, 2, "Fizz", 4, "Buzz", "Fizz", 7, 8, "Fizz", "Buzz", 11, "Fizz", 13, 14, "FizzBuzz"]

Word Frequency

Find the most common word in a sentence:

1
2
3
4
5
6
7
8
sentence = "the cat sat on the mat the"

sentence
|> String.downcase()
|> String.split()
|> Enum.frequencies()
|> Enum.max_by(fn {_word, count} -> count end)
# => {"the", 3}

Group Anagrams

Given a list of words, group together those that are anagrams of each other. The trick is to use the sorted characters as a grouping key:

1
2
3
4
5
6
words = ["eat", "tea", "tan", "ate", "nat", "bat"]

Enum.group_by(words, fn word ->
  word |> String.graphemes() |> Enum.sort() |> Enum.join()
end)
# => %{"abt" => ["bat"], "aet" => ["eat", "tea", "ate"], "ant" => ["tan", "nat"]}

Real Data Processing

Let’s fetch real data and process it. We’ll use the Req package (same as Part 1) to pull GitHub repos for the elixir-lang org, then find the top 5 by star count:

1
2
3
4
5
6
7
8
9
10
11
{:ok, response} = Req.get("https://api.github.com/orgs/elixir-lang/repos?per_page=100")

response.body
|> Enum.map(fn repo -> %{name: repo["name"], stars: repo["stargazers_count"]} end)
|> Enum.sort_by(& &1.stars, :desc)
|> Enum.take(5)
# => [
#   %{name: "elixir", stars: ...},
#   %{name: "ex_doc", stars: ...},
#   ...
# ]

That’s it — fetch, transform, sort, take. The pipe chain reads like a description of the steps.

Processes — Elixir’s Superpower

The first post mentioned that Elixir uses lightweight processes for concurrency but never showed them. Let’s fix that.

A process is spawned with spawn/1, receives messages with receive, and sends messages with send/2:

1
2
3
4
5
6
7
8
9
10
11
12
13
pid = spawn(fn ->
  receive do
    {:hello, sender} ->
      send(sender, {:world, self()})
  end
end)

send(pid, {:hello, self()})

receive do
  {:world, _from} -> "process answered!"
end
# => "process answered!"

self() returns the PID of the current process — we pass it along so the spawned process knows where to reply.

Stateful Processes

Processes can hold state through recursion. Here is a simple counter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
defmodule Counter do
  def start(initial \\ 0) do
    spawn(fn -> loop(initial) end)
  end

  defp loop(count) do
    receive do
      {:increment, from} ->
        send(from, {:ok, count + 1})
        loop(count + 1)

      {:get, from} ->
        send(from, {:ok, count})
        loop(count)

      :stop ->
        :ok
    end
  end
end

pid = Counter.start()

send(pid, {:increment, self()})
receive do {:ok, v} -> v end
# => 1

send(pid, {:increment, self()})
receive do {:ok, v} -> v end
# => 2

send(pid, {:get, self()})
receive do {:ok, v} -> v end
# => 2

send(pid, :stop)

The state is never stored in a variable — it lives in the function argument, passed recursively on each message.

Note: In real Elixir applications you would use GenServer instead of writing this loop manually. GenServer wraps this exact pattern with error handling, supervision, and a cleaner API. But understanding the raw process model first makes GenServer much easier to read.

Task — Practical Concurrency

Raw processes are powerful but verbose. The Task module gives us a higher-level API for the common case: run work concurrently and collect the results.

1
2
3
4
5
6
7
task1 = Task.async(fn -> :timer.sleep(100); "result 1" end)
task2 = Task.async(fn -> :timer.sleep(100); "result 2" end)
task3 = Task.async(fn -> :timer.sleep(100); "result 3" end)

[Task.await(task1), Task.await(task2), Task.await(task3)]
# => ["result 1", "result 2", "result 3"]
# total time: ~100ms, not ~300ms

All three tasks run in parallel — the total time is roughly the time of the slowest task, not the sum.

A real-world example: fetch multiple URLs in parallel:

1
2
3
4
5
6
7
8
9
10
11
urls = [
  "https://api.github.com/repos/elixir-lang/elixir",
  "https://api.github.com/repos/phoenixframework/phoenix",
  "https://api.github.com/repos/elixir-nx/nx"
]

urls
|> Enum.map(fn url -> Task.async(fn -> Req.get(url) end) end)
|> Enum.map(&Task.await(&1, 10_000))
|> Enum.map(fn {:ok, %{status: status}} -> status end)
# => [200, 200, 200]

Compare sequential vs parallel with :timer.tc/1:

1
2
3
4
5
6
7
8
9
10
11
12
{sequential_us, _} = :timer.tc(fn ->
  Enum.map(urls, &Req.get/1)
end)

{parallel_us, _} = :timer.tc(fn ->
  urls
  |> Enum.map(&Task.async(fn -> Req.get(&1) end))
  |> Enum.map(&Task.await(&1, 10_000))
end)

"Sequential: #{div(sequential_us, 1000)}ms — Parallel: #{div(parallel_us, 1000)}ms"
# => "Sequential: ~900ms — Parallel: ~320ms"

Task.async_stream — The Idiomatic Way

Manually calling Task.async + Task.await for each element works, but Elixir has a cleaner tool for the common case of mapping concurrently over a collection: Task.async_stream/3.

1
2
3
4
[1, 2, 3, 4, 5]
|> Task.async_stream(fn n -> n * n end)
|> Enum.map(fn {:ok, result} -> result end)
# => [1, 4, 9, 16, 25]

Each element produces an {:ok, result} tuple (or {:exit, reason} on failure), and the output preserves the input order. The previous URL fetch rewritten with async_stream:

1
2
3
4
5
6
7
urls
|> Task.async_stream(fn url -> Req.get(url) end,
  max_concurrency: 3,
  timeout: 10_000
)
|> Enum.map(fn {:ok, {:ok, %{status: status}}} -> status end)
# => [200, 200, 200]

The key difference from the manual approach is max_concurrency. When you spawn tasks with Task.async directly, all of them start immediately. With async_stream, at most max_concurrency tasks run at any moment — the rest wait in line. This is important when calling rate-limited APIs or doing I/O that you don’t want to flood:

1
2
3
4
# fetch 100 URLs, but only 10 at a time
many_urls
|> Task.async_stream(&Req.get/1, max_concurrency: 10, timeout: 30_000)
|> Enum.map(fn {:ok, {:ok, resp}} -> resp.status end)

In most real-world cases, Task.async_stream is the right choice over the manual Task.async + Task.await loop.

Conclusion

We have covered Enum for data transformation, solved real exercises idiomatically, processed data from a live API, and introduced the process model that powers Elixir’s concurrency story. From here, the natural next steps are:

  • GenServer — the production-grade stateful process abstraction
  • Supervisor — for fault-tolerant process trees
  • Stream — lazy version of Enum for large or infinite data
  • Phoenix / LiveView — the web framework built on top of all of this

If you want to keep going, the Official Elixir guides are excellent and pick up exactly where this post leaves off.

Resources