Before you can build a robust Elixir application you need to be comfortable with the language’s most fundamental pieces: the interactive shell, variables, modules, functions, and the way the runtime understands types. This article rewrites those basics with fresh, real‑world examples, so you can experiment right away and see how each concept fits together.

Why These Basics Matter

  • Interactive exploration lets you prototype ideas in seconds, without creating a full project.
  • Variables and immutability shape how data flows through your functions.
  • Modules and functions provide the namespace and composability that keep code readable.
  • Operator shortcuts such as the pipeline make pipelines of transformations easy to read.
  • Visibility, imports, and aliases let you control the public surface of a module and reduce noise.
  • Module attributes give you compile‑time constants and a place for documentation.

Mastering these pieces equips you to write clean, maintainable, and idiomatic Elixir code.

1. The Interactive Shell (IEx)

The quickest way to try out Elixir snippets is the iex REPL. Start it from a terminal:

iex

Once inside, you can type any valid Elixir expression and see the result immediately. For example, let’s calculate a simple health‑point adjustment for a role‑playing game character:

iex(1)> base_hp = 120
120
iex(2)> bonus = 35
35
iex(3)> total_hp = base_hp + bonus
155

Every line you type is an expression that returns a value, and iex prints that value. The REPL also supports multi‑line expressions. The parser won’t evaluate until the code is syntactically complete:

iex(4)> result = ( 
...>   10 * ( 
...>     5 + 2 
...>   ) 
...> )
70

To leave the shell, press Ctrl‑C twice, or run System.halt() for a graceful shutdown.

2. Working with Variables

Elixir is a dynamically typed language, so you never declare a variable’s type. The = operator binds a name to a value:

iex(5)> temperature = 22.5
22.5

Names must start with a lowercase letter or an underscore, and may contain letters, numbers, and underscores. You can also terminate a name with ? or ! to signal a predicate or a potentially dangerous operation, respectively:

iex(6)> valid? = true
true
iex(7)> update! = fn map, key, val -> Map.put(map, key, val) end
#Function<27.99386840/3 in :erl_eval.expr/5>

Variables can be rebound to new data:

iex(8)> temperature = 18
18

Under the hood a new immutable value is created and the name now points to that value; the old value remains untouched until the garbage collector discards it.

3. Organizing Code with Modules

All functions in Elixir belong to a module. Think of a module as a container that groups related functions and gives them a common namespace.

3.1 Defining a Module

Let’s model a tiny inventory service for a coffee shop:

defmodule CoffeeInventory do
  @default_stock 50

  # public function: fetch the current stock level
  def stock(item) do
    Map.get(@store, item, @default_stock)
  end

  # public function: restock an item
  def restock(item, qty) do
    update_store(fn store -> Map.update(store, item, qty, &(&1 + qty)) end)
  end

  # private helper – not visible outside the module
  defp update_store(fun) do
    new_store = fun.(@store)
    # In a real system this would persist the state.
    new_store
  end

  # compile‑time constant used by all functions
  @store %{
    "espresso" => 30,
    "latte"    => 20,
    "cappuccino" => 15
  }
end

This snippet demonstrates:

  • Module attributes (@default_stock and @store) used as compile‑time constants.
  • Public functions (stock/1 and restock/2) that can be called from outside.
  • A private function (update_store/1) that only the module can use.

3.2 Loading a Module from a File

Save the code above to coffee_inventory.ex and compile it with:

elixirc coffee_inventory.ex

Then start iex in the same directory and call the functions:

iex> CoffeeInventory.stock("latte")
20
iex> CoffeeInventory.restock("latte", 10)
{:ok, %{ "espresso" => 30, "latte" => 30, "cappuccino" => 15 }}

3.3 Nested Modules and Hierarchical Names

When a project grows, you may group related modules under a common namespace:

defmodule Banking.Accounts do
  defmodule Savings do
    def interest_rate, do: 0.015
  end

  defmodule Checking do
    def overdraft_limit, do: 500
  end
end

# Usage
iex> Banking.Accounts.Savings.interest_rate()
0.015
iex> Banking.Accounts.Checking.overdraft_limit()
500

The dot notation is just syntactic sugar; each nested module compiles into its own BEAM file.

4. Functions: Arity, Overloading, and Visibility

4.1 What is Arity?

A function’s arity is the number of arguments it receives. The full identifier of a function is Module.function/arity. Different arities can coexist under the same name:

defmodule Temperature do
  # Single‑argument version: convert Celsius to Fahrenheit
  def to_fahrenheit(celsius) do
    celsius * 9 / 5 + 32
  end

  # Two‑argument version: convert a list of values in one call
  def to_fahrenheit(list) when is_list(list) do
    Enum.map(list, &to_fahrenheit/1)
  end
end

iex> Temperature.to_fahrenheit(0)
32.0
iex> Temperature.to_fahrenheit([0, 100])
[32.0, 212.0]

Notice the /1 call inside the /2 definition – the lower‑arity function is used as a helper for the higher‑arity version.

4.2 Default Arguments and Auto‑Generated Arity

Elixir lets you declare defaults. The compiler creates separate functions for each arity that can be satisfied by the defaults:

defmodule Greeter do
  # One function definition, but three arities are generated:
  # greet/1, greet/2, greet/3
  def greet(name, prefix \\ "Hello", suffix \\ "!") do
    "#{prefix} #{name}#{suffix}"
  end
end

iex> Greeter.greet("Ada")
"Hello Ada!"
iex> Greeter.greet("Ada", "Hi")
"Hi Ada!"
iex> Greeter.greet("Ada", "Good morning", ".")
"Good morning Ada."

4.3 Public vs. Private Functions

Functions defined with def are public; defp makes them private:

defmodule SecretSauce do
  def flavor, do: "umami"

  defp secret_ingredient, do: :glutamate
end

iex> SecretSauce.flavor()
"umami"
iex> SecretSauce.secret_ingredient()
** (UndefinedFunctionError) function SecretSauce.secret_ingredient/0 is undefined or private

5. Reducing Boilerplate: Imports and Aliases

5.1 Importing Functions

If you find yourself repeatedly typing the module prefix, you can import the whole module (or selected functions) into the current namespace:

defmodule LoggerDemo do
  import IO, only: [puts: 1]

  def announce(msg) do
    puts("[NOTICE] #{msg}")
  end
end

iex> LoggerDemo.announce("Server started")
[NOTICE] Server started
:ok

The only option prevents namespace clashes.

5.2 Aliasing Long Module Names

For deep module hierarchies, alias provides a short handle:

defmodule GameEngine do
  alias Gaming.Characters.Wizard, as: Mage

  def cast_spell(%Mage{} = wizard, spell) do
    # Implementation omitted
    :ok
  end
end

If the alias points to the last segment of the full name, you can drop the as: keyword:

alias Gaming.Characters.Warrior

6. Module Attributes: Constants, Documentation, and Typespecs

6.1 Compile‑Time Constants

Attributes that start with @ can store values that are substituted at compile time:

defmodule Planet do
  @gravity 9.81

  def weight(mass), do: mass * @gravity
end

iex> Planet.weight(70)
686.7

6.2 Documentation with @moduledoc and @doc

Adding documentation directly inside the source makes it discoverable via h in iex and via tools like ex_doc:

defmodule MathUtils do
  @moduledoc """
  Utility functions for simple arithmetic.
  """

  @doc "Returns the absolute value of the given number."
  def abs(x) when is_number(x), do: Kernel.abs(x)

  @doc "Calculates the factorial of a non‑negative integer."
  def factorial(0), do: 1
  def factorial(n) when n > 0, do: n * factorial(n - 1)
end

Now in the REPL you can type:

iex> h MathUtils
... (documentation appears) ...
iex> h MathUtils.factorial
... (function docs appear) ...

6.3 Typespecs for Static Analysis (Optional)

Even though Elixir is dynamically typed, you can annotate functions with @spec. Dialyzer can then verify type contracts.

defmodule Queue do
  @type t :: %{list: [any()], size: non_neg_integer()}

  @spec new() :: t()
  def new(), do: %{list: [], size: 0}

  @spec enqueue(t(), any()) :: t()
  def enqueue(%{list: lst, size: sz} = q, item) do
    %{q | list: lst ++ [item], size: sz + 1}
  end
end

Note: The @type and @spec attributes are purely for tooling; they don’t affect runtime behavior.

7. The Pipeline Operator (|>)

The pipe makes a left‑to‑right flow of data explicit. It takes the result of the expression on its left and passes it as the first argument to the function on its right.

iex> "Elixir"
|> String.upcase()
|> String.reverse()
|> String.slice(0, 4)
|> IO.puts()
IXREU
:ok

The same pipeline would be written without |> as:

IO.puts(String.slice(String.reverse(String.upcase("Elixir")), 0, 4))

For long pipelines, line‑breaks improve readability, but this syntax only works inside source files, not directly in the REPL.

8. Commenting Your Code

Comments start with # and run to the end of the line. Elixir does not have block comments, so prepend # to each line you want to silence:

# Compute the average latency of a set of ping times.
defmodule Network do
  def avg_latency(times) do
    total = Enum.sum(times)
    count = length(times)
    total / count
  end
end

9. Brief Overview of the Type System

Even though you won’t write static type declarations for every value, knowing the primitive types helps you reason about code.

  • Numbers: Integers (arbitrary size) and floats. The division operator / always yields a float; use div/2 and rem/2 for integer division and remainder.
  • Atoms: Constant literals starting with :. They are often used as identifiers or enum‑like values.
  • Binaries (strings): Written with double quotes, e.g. "hello". They hold UTF‑8 encoded bytes.
  • Lists: Linked‑list structures denoted by square brackets, e.g. [1, 2, 3].
  • Tuples: Fixed‑size collections, e.g. {:ok, result}.
  • Maps: Key/value stores, e.g. %{name: "Alice", age: 30}.

For more complex domains you can combine these primitives, but remember: data is immutable. Every transformation returns a brand‑new value.

Common Pitfalls and How to Avoid Them

  1. Confusing variable rebinding with mutation – rebinding creates a new value; the old one stays untouched.
  2. Omitting parentheses in ambiguous calls – while optional, using them reduces surprises, especially in pipelines.
  3. Namespace collisions – importing many modules without only or except can shadow functions. Use explicit imports or aliases.
  4. Using the same name for unrelated arities without delegating – keep lower‑arity functions as thin wrappers around the higher‑arity version to avoid duplicated logic.
  5. Leaking private helpers – always declare helper functions with defp unless they’re part of the public API.
  6. Misunderstanding the pipe operator – it only passes the left‑hand value as the first argument. If a function expects a different position, wrap the call in an anonymous function.

Summary: The Essentials at a Glance

  • IEx – instant feedback loop for experimenting.
  • Variables – immutable bindings that can be rebound.
  • Modules – namespaces that host public and private functions.
  • Arity & defaults – the combination module.fun/arity uniquely identifies a function; defaults generate multiple arities automatically.
  • Visibilitydef vs defp.
  • Imports & Aliases – reduce verbosity for frequently used modules.
  • Module attributes – compile‑time constants, documentation (@moduledoc, @doc), and optional @spec for static analysis.
  • Pipeline operator – expressive left‑to‑right data flow.
  • Comments – single‑line # only.
  • Basic types – numbers, atoms, strings, lists, tuples, maps, and the immutability guarantee.

With these building blocks firmly in place, you’ll be ready to explore more advanced functional patterns, concurrency primitives, and OTP design principles in the chapters that follow.