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