Rodin 21a5ea0d58 feat: comprehensive Elixir patterns guide with source citations
25 patterns extracted from the Elixir standard library source code,
each with exact file:line citations for authoritative reference.

Covers: multi-clause dispatch, type specialization, tagged tuples,
protocols, use/__using__, behaviours, binary parsing, supervision,
GenServer patterns, Agent wrappers, options validation, pipe operator,
Enumerable/Collectable protocols, Task async/await, documentation
conventions, parameterized testing, error messages, defoverridable,
infinity sentinels, defguard, Erlang delegation, flexible APIs, and
naming conventions.
2026-04-29 22:57:22 -07:00

Idiomatic Elixir: Patterns from the Source

A reference guide to writing Elixir the way the core team writes it, derived from studying the Elixir standard library source code (v1.18+). Every pattern cites the specific file and line number where you can see it in practice.

All paths are relative to the Elixir source root: lib/elixir/lib/ unless noted otherwise.


1. Multi-Clause Functions as Control Flow

The most pervasive pattern in idiomatic Elixir: instead of cond, case, or if/else chains inside a function body, express different cases as separate function clauses. The BEAM's pattern matching engine handles dispatch.

Exhaustive dispatch via clauses

# lib/elixir/lib/enum.ex:1766-1775
def map_every(enumerable, 1, fun), do: map(enumerable, fun)
def map_every(enumerable, 0, _fun), do: to_list(enumerable)
def map_every([], nth, _fun) when is_integer(nth) and nth > 1, do: []

def map_every(enumerable, nth, fun) when is_integer(nth) and nth > 1 do
  {res, _} = reduce(enumerable, {[], :first}, R.map_every(nth, fun))
  :lists.reverse(res)
end

Each clause handles one logical case. No nested conditionals. The guard clauses (when) serve as assertions that would otherwise be runtime checks.

Recursive traversal with base cases

# lib/elixir/lib/enum.ex:4541-4549
defp filter_list([head | tail], fun) do
  if fun.(head) do
    [head | filter_list(tail, fun)]
  else
    filter_list(tail, fun)
  end
end

defp filter_list([], _fun) do
  []
end

The empty list clause is the termination condition. The [head | tail] clause processes one element and recurses. This replaces loops entirely.

Mathematical recursion by squaring

# lib/elixir/lib/kernel.ex:4617-4626
defp integer_pow(_, _, 0),
  do: 1

defp integer_pow(b, a, 1),
  do: b * a

defp integer_pow(b, a, e) when :erlang.band(e, 1) == 0,
  do: integer_pow(b * b, a, :erlang.bsr(e, 1))

defp integer_pow(b, a, e),
  do: integer_pow(b * b, a * b, :erlang.bsr(e, 1))

Four clauses implement exponentiation by squaring. No mutable state, no loop counters. Each clause is a case in the mathematical definition.


2. Type-Specialized Clauses (Optimistic Dispatch)

When a function works on multiple types, provide specialized clauses for the common/fast case, with a general fallback.

List-optimized map/2

# lib/elixir/lib/enum.ex:1724-1732
def map(enumerable, fun) when is_list(enumerable) do
  :lists.map(fun, enumerable)
end

def map(first..last//step, fun) do
  map_range(first, last, step, fun)
end

def map(enumerable, fun) do
  reduce(enumerable, [], R.map(fun)) |> :lists.reverse()
end

Lists get the fast :lists.map path. Ranges get their own optimized path. Everything else goes through the generic reduce. The caller never picks — the VM dispatches to the right clause.

Access.fetch/2 — struct, map, keyword, nil

# lib/elixir/lib/access.ex:249-275
def fetch(%module{} = container, key) do
  module.fetch(container, key)
end

def fetch(map, key) when is_map(map) do
  case map do
    %{^key => value} -> {:ok, value}
    _ -> :error
  end
end

def fetch(list, key) when is_list(list) and is_atom(key) do
  case :lists.keyfind(key, 1, list) do
    {_, value} -> {:ok, value}
    false -> :error
  end
end

def fetch(nil, _key) do
  :error
end

Each data type gets its own clause. Structs delegate to their module's implementation. This is how polymorphism works without inheritance.


3. The {:ok, value} | {:error, reason} Convention

Elixir uses tagged tuples to signal success/failure without exceptions. The !-suffix variant raises on failure.

Pattern: Provide both variants

# lib/elixir/lib/keyword.ex:272-296
def validate(keyword, values) when is_list(keyword) and is_list(values) do
  validate(keyword, values, [], keyword, [])
end

# ...returns {:ok, keyword()} | {:error, [atom]}
# lib/elixir/lib/keyword.ex:355
def validate!(keyword, values) do
  case validate(keyword, values) do
    {:ok, keyword} -> keyword
    {:error, keys} -> raise ArgumentError, "unknown keys #{inspect(keys)}..."
  end
end

The non-bang version returns tagged tuples for programmatic handling. The bang version raises and is for situations where failure is unexpected. This duality appears throughout: File.read/1 vs File.read!/1, Map.fetch/2 vs Map.fetch!/2.

with for happy-path chaining

# lib/elixir/lib/uri.ex:486-492
defp unpercent(<<?%, tail::binary>>, acc, spaces) do
  with <<hex1, hex2, tail::binary>> <- tail,
       dec1 when is_integer(dec1) <- hex_to_dec(hex1),
       dec2 when is_integer(dec2) <- hex_to_dec(hex2) do
    unpercent(tail, <<acc::binary, bsl(dec1, 4) + dec2>>, spaces)
  else
    _ -> unpercent(tail, <<acc::binary, ?%>>, spaces)
  end
end

with chains operations where each step can fail. If any <- doesn't match, execution falls to else. This replaces nested case statements.

The with best practice (from the docs)

# lib/elixir/lib/kernel/special_forms.ex:1679-1710 (from `with` documentation)
# WRONG: reconstructing error types in else (line 1679)
with ".ex" <- Path.extname(path),
     true <- File.exists?(path) do
  ...
else
  binary when is_binary(binary) -> {:error, :invalid_extension}
  false -> {:error, :missing_file}
end

# RIGHT: normalize in helper functions so each <- returns clear errors (line 1697)
with :ok <- validate_extension(path),
     :ok <- validate_exists(path) do
  ...
end

The Elixir docs (lib/elixir/lib/kernel/special_forms.ex:1692-1710) explicitly recommend that each <- clause return a normalized format. Extract validation into named helper functions rather than decoding raw return values in else.


4. Protocols for Polymorphism

Protocols are Elixir's answer to polymorphism. They define a contract that any type can implement.

Defining a protocol

# lib/elixir/lib/json.ex:1-117
defprotocol JSON.Encoder do
  @moduledoc "A protocol for custom JSON encoding..."
  def encode(term, encoder)
end

Implementing for specific types

# lib/elixir/lib/json.ex:120-128
defimpl JSON.Encoder, for: Atom do
  def encode(value, encoder) do
    case value do
      nil -> "null"
      true -> "true"
      false -> "false"
      _ -> encoder.(Atom.to_string(value), encoder)
    end
  end
end

# lib/elixir/lib/json.ex:130-134
defimpl JSON.Encoder, for: BitString do
  def encode(value, _encoder) do
    :json.encode_binary(value)
  end
end

Deriving protocol implementations

# lib/elixir/lib/inspect.ex:78-80 (from moduledoc example)
defmodule User do
  @derive {Inspect, only: [:id, :name]}
  defstruct [:id, :name, :address]
end
# lib/elixir/lib/json.ex:12-14 (from JSON.Encoder @moduledoc)
@derive {JSON.Encoder, only: [...]}
defstruct ...

@derive generates a protocol implementation at compile time. The only: option prevents accidentally leaking private fields.


5. The use Macro and __using__/1 Pattern

use is not inheritance. It's compile-time code injection. The convention is to use it for setting up behaviours and generating boilerplate.

What use GenServer actually does

# lib/elixir/lib/gen_server.ex:899-1002
defmacro __using__(opts) do
  quote location: :keep, bind_quoted: [opts: opts] do
    @behaviour GenServer                          # line 901

    def child_spec(init_arg) do                   # line 911
      default = %{
        id: __MODULE__,
        start: {__MODULE__, :start_link, [init_arg]}
      }
      Supervisor.child_spec(default, unquote(Macro.escape(opts)))
    end

    defoverridable child_spec: 1                  # line 921

    @before_compile GenServer                     # line 924

    def handle_call(msg, _from, state) do         # line 926 — raises with helpful error
      ...
    end

    def handle_info(msg, state) do                # line 943 — logs warning about unhandled messages
      ...
    end

    def handle_cast(msg, state) do                # line 973 — raises with helpful error
      ...
    end

    def terminate(_reason, _state), do: :ok       # line 992
    def code_change(_old, state, _extra), do: {:ok, state}  # line 997

    defoverridable code_change: 3, terminate: 2,  # line 1002
                   handle_info: 2, handle_cast: 2, handle_call: 3
  end
end

It sets the behaviour, defines child_spec/1, injects sensible default callbacks (that raise helpful errors), and marks them as overridable. The @before_compile hook warns if init/1 is missing.

The anti-pattern (from Kernel docs)

# lib/elixir/lib/kernel.ex:6087-6096 (documentation for `use/2`)
# DON'T do this — just use `import` directly
defmodule MyModule do
  defmacro __using__(_opts) do
    quote do
      import MyModule
    end
  end
end

The use/2 docs (lib/elixir/lib/kernel.ex:6060-6096) explicitly say: don't use __using__ if all it does is import the module. Let callers import/alias directly.


6. Behaviour Callbacks with @impl

Behaviours define callbacks. @impl true marks which functions fulfill which contract.

Defining callbacks

# lib/elixir/lib/gen_server.ex:577 (@callback init)
@callback init(init_arg :: term) ::
  {:ok, state}
  | {:ok, state, timeout | :hibernate | {:continue, continue_arg}}
  | :ignore
  | {:stop, reason :: any}

# lib/elixir/lib/gen_server.ex:647 (@callback handle_call)
@callback handle_call(request :: term, from, state :: term) ::
  {:reply, reply, new_state}
  | {:reply, reply, new_state, timeout | :hibernate | {:continue, continue_arg}}
  | {:noreply, new_state}
  | ...

# lib/elixir/lib/gen_server.ex:853
@optional_callbacks code_change: 3, terminate: 2, format_status: 1, format_status: 2

Implementing with @impl

# lib/elixir/lib/exception.ex:1102-1103
@impl true
def blame(%{message: message} = exception, [{:erlang, fun, args, _} | _] = stacktrace) do
  ...
end

@impl true tells both the compiler and readers: "this function is fulfilling a behaviour contract." The compiler will warn if you annotate a function that doesn't match any callback.


7. Binary Pattern Matching for Parsing

Elixir inherits Erlang's powerful binary pattern matching. The standard library uses it extensively for parsing.

Recursive binary parser

# lib/elixir/lib/option_parser.ex:600-630
# If we have an escaped quote, simply remove the escape
defp do_split(<<?\\, quote, t::binary>>, buffer, acc, quote),
  do: do_split(t, <<buffer::binary, quote>>, acc, quote)

# If we have a quote and we were not in a quote, start one
defp do_split(<<quote, t::binary>>, buffer, acc, nil) when quote in [?", ?'],
  do: do_split(t, buffer, acc, quote)

# If we have a quote and we were inside it, close it
defp do_split(<<quote, t::binary>>, buffer, acc, quote),
  do: do_split(t, buffer, acc, nil)

# If we have space and we are outside of a quote, start new segment
defp do_split(<<?\s, t::binary>>, buffer, acc, nil),
  do: do_split(String.trim_leading(t, " "), "", [buffer | acc], nil)

# All other characters are moved to buffer
defp do_split(<<h, t::binary>>, buffer, acc, quote) do
  do_split(t, <<buffer::binary, h>>, acc, quote)
end

# Finish the string expecting a nil marker
defp do_split(<<>>, "", acc, nil), do: Enum.reverse(acc)
defp do_split(<<>>, buffer, acc, nil), do: Enum.reverse([buffer | acc])

This is a state machine encoded as function clauses. The quote parameter tracks parser state (inside quotes or not). Each clause handles one character class. No regex, no mutable state.

Compile-time generated clauses for character matching

# lib/elixir/lib/string.ex:345-356
for char <- 0x20..0x7E do
  defp recur_printable?(<<unquote(char), rest::binary>>, character_limit) do
    recur_printable?(rest, decrement(character_limit))
  end
end

for char <- [?\n, ?\r, ?\t, ?\v, ?\b, ?\f, ?\e, ?\d, ?\a] do
  defp recur_printable?(<<unquote(char), rest::binary>>, character_limit) do
    recur_printable?(rest, decrement(character_limit))
  end
end

Metaprogramming generates one function clause per printable character. The BEAM compiles this into an efficient jump table. This is faster than runtime range checks.


8. Supervisor and Child Specs

The OTP supervision tree pattern is central to Elixir applications. The standard library shows exactly how to structure it.

child_spec/1 — the universal entry point

# lib/elixir/lib/gen_server.ex:911-920
def child_spec(init_arg) do
  default = %{
    id: __MODULE__,
    start: {__MODULE__, :start_link, [init_arg]}
  }
  Supervisor.child_spec(default, unquote(Macro.escape(opts)))
end

defoverridable child_spec: 1

Every supervised module defines child_spec/1. It returns a map with :id and :start. Users override it via defoverridable to customize restart strategies.

Supervisor's multi-clause init_child

# lib/elixir/lib/supervisor.ex:816-843
defp init_child(module) when is_atom(module) do
  init_child({module, []})
end

defp init_child({module, arg}) when is_atom(module) do
  try do
    module.child_spec(arg)
  rescue
    e in UndefinedFunctionError ->
      case __STACKTRACE__ do
        [{^module, :child_spec, [^arg], _} | _] ->
          raise ArgumentError, child_spec_error(module)
        stack ->
          reraise e, stack
      end
  end
end

defp init_child(map) when is_map(map) do
  map
end

defp init_child(other) do
  raise ArgumentError, """
  supervisors expect each child to be one of the following:
    * a module
    * a {module, arg} tuple
    * a child specification as a map with at least the :id and :start fields
  Got: #{inspect(other)}
  """
end

Three valid forms, one error clause. The error message tells you exactly what's acceptable. The rescue clause catches the specific case where child_spec/1 isn't defined and provides a helpful message instead of a cryptic UndefinedFunctionError.


9. GenServer Patterns

Separate client API from server callbacks

# lib/elixir/lib/gen_server.ex:39-61 (Stack example in moduledoc)
defmodule Stack do
  use GenServer

  # Callbacks (server-side)
  @impl true
  def init(elements) do                          # line 44
    initial_state = String.split(elements, ",", trim: true)
    {:ok, initial_state}
  end

  @impl true
  def handle_call(:pop, _from, state) do         # line 50
    [to_caller | new_state] = state
    {:reply, to_caller, new_state}
  end

  @impl true
  def handle_cast({:push, element}, state) do    # line 56
    new_state = [element | state]
    {:noreply, new_state}
  end
end

Multi-clause cast/2 for different server locations

# lib/elixir/lib/gen_server.ex:1200-1225
def cast({:global, name}, request) do
  try do
    :global.send(name, cast_msg(request))
    :ok
  catch
    _, _ -> :ok
  end
end

def cast({:via, mod, name}, request) do
  try do
    mod.send(name, cast_msg(request))
    :ok
  catch
    _, _ -> :ok
  end
end

def cast({name, node}, request) when is_atom(name) and is_atom(node),
  do: do_send({name, node}, cast_msg(request))

def cast(dest, request) when is_atom(dest) or is_pid(dest),
  do: do_send(dest, cast_msg(request))

cast always returns :ok regardless of whether delivery succeeded — fire-and-forget semantics. Different clauses handle different server location strategies.


10. Agent as a Thin GenServer Wrapper

The Agent module demonstrates how to build focused abstractions over GenServer.

# lib/elixir/lib/agent.ex:280-282
def start_link(fun, options \\ []) when is_function(fun, 0) do
  GenServer.start_link(Agent.Server, fun, options)
end
# lib/elixir/lib/agent.ex:344-346
def get(agent, fun, timeout \\ 5000) when is_function(fun, 1) do
  GenServer.call(agent, {:get, fun}, timeout)
end
# lib/elixir/lib/agent.ex:426-428
def update(agent, fun, timeout \\ 5000) when is_function(fun, 1) do
  GenServer.call(agent, {:update, fun}, timeout)
end

The entire Agent API is thin wrappers around GenServer.call and GenServer.cast. No custom process loop. No reinvented wheel. This is the "right abstraction depth" — expose a focused API that hides the message-passing mechanics.


11. Options Validation Pattern

The standard library has a clear convention for validating keyword-list options.

Early, explicit validation with helpful errors

# lib/elixir/lib/registry.ex:380-452
def start_link(options) do
  keys = Keyword.get(options, :keys)

  kind =
    case keys do
      {:duplicate, partition_strategy} when partition_strategy in [:key, :pid] ->
        {:duplicate, partition_strategy}
      :unique -> :unique
      :duplicate -> {:duplicate, :pid}
      _ ->
        raise ArgumentError,
          "expected :keys to be given and be one of :unique, :duplicate, " <>
          "{:duplicate, :key}, or {:duplicate, :pid}, got: #{inspect(keys)}"
    end

  name =
    case Keyword.fetch(options, :name) do
      {:ok, name} when is_atom(name) -> name
      {:ok, other} ->
        raise ArgumentError, "expected :name to be an atom, got: #{inspect(other)}"
      :error ->
        raise ArgumentError, "expected :name option to be present"
    end
  ...
end

Keyword.validate!/2 for simpler cases

# lib/elixir/lib/keyword.ex:272-300 (validate/2 implementation)
def validate(keyword, values) when is_list(keyword) and is_list(values) do
  validate(keyword, values, [], keyword, [])
end
# Returns {:ok, keyword_with_defaults} | {:error, invalid_keys}

# lib/elixir/lib/keyword.ex:355-361 (validate!/2 — the raising wrapper)
def validate!(keyword, values) do
  case validate(keyword, values) do
    {:ok, keyword} -> keyword
    {:error, keys} -> raise ArgumentError, ...
  end
end

Use Keyword.validate!/2 when your options are simple atoms with defaults. Use explicit case chains (like Registry does at lib/elixir/lib/registry.ex:380-452) when options have complex constraints or interdependencies.


12. The Pipe Operator and Pipeline-Friendly APIs

How |> works

# lib/elixir/lib/kernel.ex:4509-4514
defmacro left |> right do
  fun = fn {x, pos}, acc ->
    Macro.pipe(acc, x, pos)
  end

  :lists.foldl(fun, left, Macro.unpipe(right))
end

The pipe operator is a macro that rewrites a |> f(b) into f(a, b) at compile time. Zero runtime cost.

tap/2 for side effects in pipelines

# lib/elixir/lib/kernel.ex:1403-1408
defmacro tap(value, fun) do
  quote bind_quoted: [fun: fun, value: value] do
    _ = fun.(value)
    value
  end
end

tap runs a function for its side effect and returns the original value unchanged. The _ = suppresses unused-return warnings.

then/2 for non-first-argument piping

# lib/elixir/lib/kernel.ex:2836-2840
defmacro then(value, fun) do
  quote do
    unquote(fun).(unquote(value))
  end
end

then passes the piped value to a function that returns a new value. Use it when the piped value isn't the first argument: value |> then(&Map.get(other, &1)).

Design your APIs pipe-first

The convention throughout the standard library: the "subject" (the data being transformed) is always the first argument.

# Enum — enumerable always first
Enum.map(list, &transform/1)       # lib/elixir/lib/enum.ex:1724
Enum.filter(list, &predicate/1)    # lib/elixir/lib/enum.ex:1119

# Map — map always first
Map.put(map, key, value)           # lib/elixir/lib/map.ex:646
Map.get(map, key)                  # lib/elixir/lib/map.ex:587

# String — string always first
String.split(string, pattern)      # lib/elixir/lib/string.ex:516
String.trim(string)                # lib/elixir/lib/string.ex:1380

This enables natural pipelines:

data
|> Enum.filter(&valid?/1)
|> Enum.map(&transform/1)
|> Enum.sort_by(& &1.priority)

13. Enumerable Protocol and Reduce as Foundation

All enumeration in Elixir is built on a single primitive: reduce/3.

The Enumerable protocol

# lib/elixir/lib/enum.ex:177-180 (from docs)
def reduce(_list, {:halt, acc}, _fun), do: {:halted, acc}
def reduce(list, {:suspend, acc}, fun), do: {:suspended, acc, &reduce(list, &1, fun)}
def reduce([], {:cont, acc}, _fun), do: {:done, acc}
def reduce([head | tail], {:cont, acc}, fun), do: reduce(tail, fun.(head, acc), fun)

Four clauses define the entire enumeration model. The accumulator is a tagged tuple that controls flow: :cont continues, :halt stops immediately, :suspend pauses for later resumption.

Everything is built on reduce

# lib/elixir/lib/enum.ex:2675-2677
def reduce_while(enumerable, acc, fun) do
  Enumerable.reduce(enumerable, {:cont, acc}, fun) |> elem(1)
end

Even reduce_while is a one-liner that leverages the tag-based control of the underlying protocol.


14. Stream — Lazy Composition

Streams compose transformations without executing them. Execution happens only when consumed by an eager function.

# lib/elixir/lib/stream.ex:60-66 (moduledoc example)
stream = 1..3
|> Stream.map(&IO.inspect(&1))
|> Stream.map(&(&1 * 2))
|> Stream.map(&IO.inspect(&1))
Enum.to_list(stream)
# Prints: 1, 2, 2, 4, 3, 6
# The list was enumerated just once!

The key insight: Stream.map returns a recipe (a struct containing the enumerable + the function), not a result. Only Enum.to_list/1 (or any Enum function) triggers execution.


15. Collectable — The Dual of Enumerable

While Enumerable defines how to take elements out, Collectable defines how to put elements in.

# lib/elixir/lib/collectable.ex:192-206
defimpl Collectable, for: Map do
  def into(map) do
    fun = fn
      map_acc, {:cont, {key, value}} ->
        Map.put(map_acc, key, value)
      map_acc, :done ->
        map_acc
      _map_acc, :halt ->
        :ok
    end
    {map, fun}
  end
end

The into/1 function returns {initial_accumulator, collector_function}. The collector handles three commands: {:cont, element} to add, :done to finalize, :halt to abort. This powers Enum.into/2 and for comprehensions with into:.


16. Task for Concurrent Work

Async/Await with ownership tracking

# lib/elixir/lib/task.ex:875-897
def await(%Task{ref: ref, owner: owner} = task, timeout \\ 5000) when is_timeout(timeout) do
  if owner != self() do
    raise ArgumentError, invalid_owner_error(task)
  end
  await_receive(ref, task, timeout)
end

# lib/elixir/lib/task.ex:883-897
defp await_receive(ref, task, timeout) do
  receive do
    {^ref, reply} ->
      demonitor(ref)
      reply
    {:DOWN, ^ref, _, proc, reason} ->
      exit({reason(reason, proc), {__MODULE__, :await, [task, timeout]}})
  after
    timeout ->
      demonitor(ref)
      exit({:timeout, {__MODULE__, :await, [task, timeout]}})
  end
end

Key details: Tasks enforce ownership (only the spawning process can await). The ^ref pin in receive ensures you only match YOUR task's response. The :DOWN handler means you get clean exits if the task crashes.


17. Documentation as Code

DocTests — examples that are tests

# lib/elixir/lib/enum.ex:480-496 (Enum.at/3 documentation)
@doc """
...
## Examples

    iex> Enum.at([2, 4, 6], 0)
    2

    iex> Enum.at([2, 4, 6], 4)
    nil

    iex> Enum.at([2, 4, 6], 4, :none)
    :none
"""

Every iex> block in @doc is automatically extracted and run as a test by ExUnit. This guarantees documentation examples are always correct.

Typespec annotations

# lib/elixir/lib/enum.ex:1722-1724
@spec map(t, (element -> any)) :: list
def map(enumerable, fun)

def map(enumerable, fun) when is_list(enumerable) do

The @spec immediately precedes the function head. Use generic typespec variables (t, element) from @type definitions in the module.

@doc since: for version tracking

# lib/elixir/lib/enum.ex:1763-1765
@doc since: "1.4.0"
@spec map_every(t, non_neg_integer, (element -> any)) :: list
def map_every(enumerable, nth, fun)

Deprecation without removal

# lib/elixir/lib/enum.ex:503-505
@doc false
@deprecated "Use Enum.chunk_every/2 instead"
def chunk(enumerable, count), do: chunk(enumerable, count, count, nil)

@doc false hides from documentation. @deprecated emits compile-time warnings. The function still works — no breaking change.


18. Parameterized Testing

# lib/elixir/test/elixir/registry_test.exs:13-19
use ExUnit.Case,
  async: true,
  parameterize:
    for(
      keys <- [:unique, :duplicate, {:duplicate, :pid}, {:duplicate, :key}],
      partitions <- [1, 8],
      do: %{keys: keys, partitions: partitions}
    )

ExUnit's parameterize option (since v1.18) runs the same tests with different configurations. Combined with async: true, different parameter sets run concurrently.


19. Error Messages Tell You What's Acceptable

Throughout the codebase, error messages don't just say what went wrong — they say what was expected:

# lib/elixir/lib/supervisor.ex:843-856
raise ArgumentError, """
supervisors expect each child to be one of the following:

  * a module
  * a {module, arg} tuple
  * a child specification as a map with at least the :id and :start fields

Got: #{inspect(other)}
"""
# lib/elixir/lib/registry.ex:402-404
raise ArgumentError,
  "expected :keys to be given and be one of :unique, :duplicate, " <>
  "{:duplicate, :key}, or {:duplicate, :pid}, got: #{inspect(keys)}"

Pattern: "expected X, got: #{inspect(actual_value)}". Always inspect the bad value so the developer sees what they actually passed.


20. defoverridable for Extension Points

# lib/elixir/lib/gen_server.ex:911-921
def child_spec(init_arg) do
  ...
end
defoverridable child_spec: 1

# lib/elixir/lib/gen_server.ex:1002
defoverridable code_change: 3, terminate: 2, handle_info: 2,
              handle_cast: 2, handle_call: 3

defoverridable provides sensible defaults that modules can replace. The key insight: inject working defaults first, THEN mark overridable. Users only override what they need.


21. The decrement(:infinity) Idiom

When a limit might be "no limit," use :infinity as a sentinel and handle it in a dedicated clause:

# lib/elixir/lib/string.ex:368-369
defp decrement(:infinity), do: :infinity
defp decrement(character_limit), do: character_limit - 1

This avoids sentinel values like -1 or nil. The :infinity atom is self-documenting and impossible to confuse with a valid numeric value.


22. Guard Definitions with defguard

# lib/elixir/lib/kernel.ex:5886-5916 (defguard documentation and definition)
defmodule Integer.Guards do
  defguard is_even(value) when is_integer(value) and rem(value, 2) == 0
end

Custom guards can be used in function heads and case/cond/receive clauses. They must only use guard-safe expressions (no function calls that might have side effects).


23. Delegation to Erlang

The standard library frequently delegates to Erlang when it's the right tool:

# lib/elixir/lib/enum.ex:3221-3223
def sort(enumerable) when is_list(enumerable) do
  :lists.sort(enumerable)
end
# lib/elixir/lib/gen_server.ex:1318-1320
def reply(client, reply) do
  :gen.reply(client, reply)
end
# lib/elixir/lib/json.ex:131-133
defimpl JSON.Encoder, for: BitString do
  def encode(value, _encoder) do
    :json.encode_binary(value)
  end
end

Don't rewrite what Erlang already does well. Wrap it with an Elixir-idiomatic API (keyword options, {:ok, _} tuples, pipe-friendly argument order).


24. sort/2 Accepting Multiple Forms

A flexible API accepts different input shapes:

# lib/elixir/lib/enum.ex:3305-3325
def sort(enumerable, sorter) when is_list(enumerable) do
  case sorter do
    :asc -> :lists.sort(enumerable)
    :desc -> :lists.sort(enumerable) |> :lists.reverse()
    _ -> :lists.sort(to_sort_fun(sorter), enumerable)
  end
end

defp to_sort_fun(sorter) when is_function(sorter, 2), do: sorter
defp to_sort_fun(:asc), do: &<=/2
defp to_sort_fun(:desc), do: &>=/2
defp to_sort_fun(module) when is_atom(module), do: &(module.compare(&1, &2) != :gt)
defp to_sort_fun({:asc, module}) when is_atom(module), do: &(module.compare(&1, &2) != :gt)
defp to_sort_fun({:desc, module}) when is_atom(module), do: &(module.compare(&1, &2) != :lt)

The sorter argument accepts: a 2-arity function, :asc/:desc atoms, a module with compare/2, or a {:asc/:desc, module} tuple. Private to_sort_fun normalizes all forms to a function. The public API is flexible; the internals are uniform.


25. Naming Conventions

From the codebase patterns:

Convention Example Source
fetch returns {:ok, val} | :error Access.fetch/2 lib/elixir/lib/access.ex:247
fetch! raises on missing Access.fetch!/2 lib/elixir/lib/access.ex:291
get returns value or default Access.get/3 lib/elixir/lib/access.ex:318
is_ prefix for guards Kernel.is_struct/1 lib/elixir/lib/kernel.ex:2624
new for struct construction MapSet.new/0 lib/elixir/lib/map_set.ex:82
to_ for type conversion Kernel.to_timeout/1 lib/elixir/lib/kernel.ex:6405
from_ for parsing Date.from_iso8601/1 lib/elixir/lib/calendar/date.ex:360
Private helpers: do_* or *_list do_split, filter_list lib/elixir/lib/option_parser.ex:600, lib/elixir/lib/enum.ex:4541

Summary: The Elixir Way

  1. Express logic as function clauses, not nested conditionals
  2. Put the subject first for pipe-friendliness
  3. Return {:ok, _} | {:error, _} for operations that can fail; provide ! variants
  4. Use protocols for type-based polymorphism, not runtime type checks
  5. Validate options early with helpful error messages that say what's expected
  6. Delegate to Erlang when it has the right primitive; wrap with Elixir conventions
  7. Write examples as doctests — they're documentation and tests simultaneously
  8. Use @impl true on every behaviour callback so the compiler verifies you
  9. Separate client API from server callbacks in GenServer modules
  10. Design for the pipe — transformations compose left-to-right

Generated from Elixir source (HEAD, commit as of 2026-04-29). All file paths relative to the repository root. Line numbers verified against the current main branch by direct inspection via sed -n.

S
Description
Idiomatic Elixir and Phoenix patterns extracted from source code with file path + line number citations
Readme MIT 359 KiB
Languages
Markdown 100%