Skip to main content

Command Palette

Search for a command to run...

Attempt to Learn Elixir: Functions and Patterns

Updated
7 min read
Attempt to Learn Elixir: Functions and Patterns
S

A life long learner and tech tinkerer. Making and breaking systems. Learning from mistakes.

Elixir is a functional programming language. Functional languages are built with functions as first-class citizens and tend to involve a declarative style of programming, where we specify what is to be done rather than focusing on how to do it. Functions are meant to be small and simple, and bigger functions are made by composing the smaller functions together.

Pattern Matching & Immutability

In Elixir = is not assignment operator. It's the match operator. It asserts that the left side matches the right side, and binds any unbound variables on the left to make that true.

x = 1

Read this as: "Match the pattern x against the value 1. x is unbound, so bind it to 1 to make the match succeed." It happens to look like assignment, which is the trap.

Now the part that makes us confused:

1 = x   # This is legal. It succeeds.

Here x is already bound to 1. The left pattern 1 matches the right value 1. Match succeeds, nothing is bound. In many languages this is a syntax error; in Elixir it's a true assertion.

2 = x   # ** (MatchError) no match of right hand side value: 1

2 does not match 1. MatchError. This is not an exception you fear, it's the language telling you your assumption about a value's shape was wrong. You will use this deliberately.

In Elixir, data is immutable. You don't mutate; you produce a new value:

xs = [1, 2, 3]
List.insert_at(xs, 3, 4)   # returns [1, 2, 3, 4]
xs                         # still [1, 2, 3] — unchanged

"But isn't copying everything slow?" No, the runtime uses persistent data structures with structural sharing. The new list shares most of its memory with the old one; only the changed spine is new. You get value semantics without the copy cost you'd fear in C.

Why you should care?

With no shared mutable state, two processes can run truly in parallel with zero locks. The entire "let it crash and respawn" model depends on this: a crashing process can't have left shared state half-written, because there is no shared mutable state. This is the foundation of the fault tolerance you're here for.

Destructuring: matching against shapes

Matching gets powerful when the left side is a structure. The match binds the pieces.

[a, b, c] = [1, 2, 3]   # a = 1, b = 2, c = 3
{status, payload} = {:ok, "hello"}   # status = :ok, payload = "hello"
%{name: n} = %{name: "Shah", role: "lead"}   # n = "Shah"; extra keys ignored

Lists have a special, deeply important pattern — head and tail:

[head | tail] = [1, 2, 3]   # head = 1, tail = [2, 3]
[first, second | rest] = [10, 20, 30, 40]   # first=10, second=20, rest=[30,40]

This [head | tail] pattern is how recursion over lists works in Elixir.

A match that doesn't fit raises and that's a feature:

{:ok, value} = {:error, :timeout}   # ** (MatchError)

This is the heart of the tagged-tuple convention that pervades Elixir and OTP. Functions return {:ok, result} or {:error, reason}, and you match to branch.


Functions, Multiple Clauses & Guards

Elixir functions are pattern matching with a name. A single "function" is really a stack of clauses, and the runtime picks one by matching the arguments. Combined with guards, this replaces most of the if/else if/switch ladders you write in Go and Python with flat, declarative dispatch.

Our instinct will be to write one function with a case/if chain inside. But the Elixir way is many small clauses. Learning to think in clauses is most of becoming fluent.

No return. A function returns its last expression. Always. There is no early return; you restructure with clauses instead. This feels restrictive for about a day, then liberating.

do: shorthand vs do…end. One-liners use do:; multi-line bodies use a block:

def classify(code), do: code            # shorthand
def classify(code) do                   # block form
  String.upcase(code)
end

Public vs private: def is callable from outside the module; defp is private to it.

Functions are identified by name and arity name/arity. This is genuinely new coming from other languages. classify/1 and classify/2 are different functions, not overloads of one. When an error says classify/1, the /1 is the argument count. Internalize this notation; the whole ecosystem speaks it.

defmodule Demo do
  def f(a), do: {:one, a}
  def f(a, b), do: {:two, a, b}   
  # f/2 is a separate function from f/1
end

Multiple clauses (dispatch by shape)

This is the heart of the lesson. You write several clauses with the same name and arity; the runtime tries them top to bottom and runs the first whose pattern matches.

defmodule Cwmp.Event do
  def classify("0 BOOTSTRAP"),          do: :bootstrap
  def classify("1 BOOT"),               do: :boot
  def classify("2 PERIODIC"),           do: :periodic
  def classify("4 VALUE CHANGE"),       do: :value_change
  def classify("6 CONNECTION REQUEST"), do: :connection_request
  def classify(_other),                 do: :unknown
end

Cwmp.Event.classify("1 BOOT")     # => :boot
Cwmp.Event.classify("9 WHATEVER") # => :unknown

Two rules that will bite you if ignored:

Order matters. Clauses are tried in source order. The catch-all _other clause must be last. Put it first and it shadows everything below, which Elixir will even warn you about ("this clause cannot match").

Matching extends to structure, not just literals. You can destructure arguments in the head.

def summarize({:inform, id, %{event: e}}), do: "#{id}: #{classify(e)}"
def summarize(_),                          do: "not an inform"

This is the replacement for the imperative if type == ... else if ... ladder. Each branch is its own clause, self-documenting, and impossible to fall through by accident.

Guards (when)

Patterns match shape. Guards add conditions - "this clause matches when some test also holds":

defmodule Health do
  def level(p) when p < 0.5,  do: :ok
  def level(p) when p < 0.8,  do: :warn
  def level(_p),              do: :critical
end

Health.level(0.3)  # :ok
Health.level(0.9)  # :critical

Three things you must know about guards, because they are not normal code:

Only a restricted set of operations is allowed in a guard. Comparisons (<, ==, …), boolean and/or/not, arithmetic, type checks (is_binary/1, is_integer/1, is_map/1, …), in, and a handful of Kernel functions (hd, tl, map_size, byte_size, length, elem). Arbitrary function calls are forbidden. when String.contains?(code, "BOOT") will not compile. The reason: guards must be pure, fast, and side-effect-free so the runtime can evaluate them safely during dispatch. This trips up every newcomer from imperative languages. Your reflex to call a helper in the condition won't work.

A guard that errors does not crash — the clause just fails. If a guard raises (say, arithmetic on a non-number), Elixir doesn't propagate the error; it silently treats that clause as "no match" and tries the next. This is deliberate and occasionally surprising when debugging.

Combine guards with destructuring for real power:

def valid?(%{event: e}) when is_binary(e) and byte_size(e) > 0, do: true
def valid?(_), do: false

Pattern matching and immutable data are the foundational axioms from which the entire programming language is derived. If you truly internalize these two, the rest of Elixir (pipelines, OTP, processes, and even macros) stops feeling like a list of features to memorize and starts feeling like inevitable, elegant consequences. Learning these concepts and shifting your mental model towards them is the key to mastering Elixir and functional programming.