Learning Elixir: Named Functions
Named functions represent the foundational building blocks in Elixir's modular architecture, providing structure, organization, and reusability to your code. Unlike anonymous functions, named functions are defined within modules and can be referenced by their name, making them essential for building maintainable applications. This article explores the fundamentals of defining and using named functions in Elixir. Note: The examples in this article use Elixir 1.18.3. While most operations should work across different versions, some functionality might vary. Table of Contents Introduction Defining Named Functions Function Clauses and Pattern Matching Public vs Private Functions Default Arguments Guards in Functions Documentation and Typespecs Module Attributes Best Practices Conclusion Further Reading Next Steps Introduction In previous articles, we explored advanced control structures and anonymous functions in Elixir. While anonymous functions provide flexibility and convenience for one-off operations, named functions form the building blocks of your application's architecture. Named functions in Elixir are: Defined within modules using the def or defp keywords Identified by their name and arity (number of arguments) Callable from other modules when defined as public Able to leverage pattern matching across multiple function clauses Documentable with standardized comment syntax Specifiable with type information for better tooling support Let's explore how to define, organize, and use named functions effectively in your Elixir projects. Defining Named Functions Basic Syntax The basic syntax for defining a named function in Elixir is: defmodule ModuleName do def function_name(parameter1, parameter2, ...) do # Function body end end Here's a simple example: defmodule Calculator do def add(a, b) do a + b end end You can then call this function with: iex> Calculator.add(5, 3) 8 Single-Line Function Syntax For short functions, you can use the single-line syntax: defmodule Calculator do def add(a, b), do: a + b def subtract(a, b), do: a - b def multiply(a, b), do: a * b def divide(a, b), do: a / b end This is functionally equivalent to the multi-line syntax but more concise for simple functions. Calling Functions Named functions are called using the module name followed by the function name: iex> Calculator.add(1, 2) 3 iex> Calculator.multiply(4, 5) 20 Function Return Values In Elixir, functions always return the value of the last expression evaluated: defmodule Example do def return_last_value do 1 2 "This will be returned" end def conditional_return(x) do if x > 10 do "Greater than 10" else "10 or less" end end end Testing in IEx: iex> Example.return_last_value() "This will be returned" iex> Example.conditional_return(15) "Greater than 10" iex> Example.conditional_return(5) "10 or less" Function Naming Conventions Elixir has established naming conventions that make code more readable and predictable: defmodule StringHelper do # Predicate function (returns boolean) ends with ? def valid?(string) do is_binary(string) and String.length(string) > 0 end # Function that raises an exception ends with ! def tokenize!(string) do if valid?(string) do String.split(string, " ") else raise ArgumentError, "Invalid string provided" end end # Safe version without ! doesn't raise exceptions def tokenize(string) do if valid?(string) do {:ok, String.split(string, " ")} else {:error, "Invalid string provided"} end end end Testing in IEx: iex> StringHelper.valid?("hello world") true iex> StringHelper.valid?("") false iex> StringHelper.tokenize!("hello world") ["hello", "world"] iex> StringHelper.tokenize!("") ** (ArgumentError) Invalid string provided iex> StringHelper.tokenize("hello world") {:ok, ["hello", "world"]} iex> StringHelper.tokenize("") {:error, "Invalid string provided"} Function names in Elixir: Must start with a lowercase letter (a-z) or underscore (_) Can contain alphanumeric characters, underscores, and the special ending characters ? and ! Conventionally use snake_case (lowercase with underscores) Cannot contain other special characters or operators Common conventions to follow: Functions returning booleans should end with ? (e.g., empty?, valid?, exists?) Functions that raise exceptions should end with ! (e.g., save!, fetch!, decode!) Provide both safe (returns tuple) and unsafe (raises exception) versions of important functions Use descriptive verb-noun combinations (e.g., parse_json, validate_user, calculate_total) Function Arity In Elixir, functions are identified by their name and arity (the number of arguments they accept). The combination of name and arity is often

Named functions represent the foundational building blocks in Elixir's modular architecture, providing structure, organization, and reusability to your code. Unlike anonymous functions, named functions are defined within modules and can be referenced by their name, making them essential for building maintainable applications. This article explores the fundamentals of defining and using named functions in Elixir.
Note: The examples in this article use Elixir 1.18.3. While most operations should work across different versions, some functionality might vary.
Table of Contents
- Introduction
- Defining Named Functions
- Function Clauses and Pattern Matching
- Public vs Private Functions
- Default Arguments
- Guards in Functions
- Documentation and Typespecs
- Module Attributes
- Best Practices
- Conclusion
- Further Reading
- Next Steps
Introduction
In previous articles, we explored advanced control structures and anonymous functions in Elixir. While anonymous functions provide flexibility and convenience for one-off operations, named functions form the building blocks of your application's architecture.
Named functions in Elixir are:
- Defined within modules using the
def
ordefp
keywords - Identified by their name and arity (number of arguments)
- Callable from other modules when defined as public
- Able to leverage pattern matching across multiple function clauses
- Documentable with standardized comment syntax
- Specifiable with type information for better tooling support
Let's explore how to define, organize, and use named functions effectively in your Elixir projects.
Defining Named Functions
Basic Syntax
The basic syntax for defining a named function in Elixir is:
defmodule ModuleName do
def function_name(parameter1, parameter2, ...) do
# Function body
end
end
Here's a simple example:
defmodule Calculator do
def add(a, b) do
a + b
end
end
You can then call this function with:
iex> Calculator.add(5, 3)
8
Single-Line Function Syntax
For short functions, you can use the single-line syntax:
defmodule Calculator do
def add(a, b), do: a + b
def subtract(a, b), do: a - b
def multiply(a, b), do: a * b
def divide(a, b), do: a / b
end
This is functionally equivalent to the multi-line syntax but more concise for simple functions.
Calling Functions
Named functions are called using the module name followed by the function name:
iex> Calculator.add(1, 2)
3
iex> Calculator.multiply(4, 5)
20
Function Return Values
In Elixir, functions always return the value of the last expression evaluated:
defmodule Example do
def return_last_value do
1
2
"This will be returned"
end
def conditional_return(x) do
if x > 10 do
"Greater than 10"
else
"10 or less"
end
end
end
Testing in IEx:
iex> Example.return_last_value()
"This will be returned"
iex> Example.conditional_return(15)
"Greater than 10"
iex> Example.conditional_return(5)
"10 or less"
Function Naming Conventions
Elixir has established naming conventions that make code more readable and predictable:
defmodule StringHelper do
# Predicate function (returns boolean) ends with ?
def valid?(string) do
is_binary(string) and String.length(string) > 0
end
# Function that raises an exception ends with !
def tokenize!(string) do
if valid?(string) do
String.split(string, " ")
else
raise ArgumentError, "Invalid string provided"
end
end
# Safe version without ! doesn't raise exceptions
def tokenize(string) do
if valid?(string) do
{:ok, String.split(string, " ")}
else
{:error, "Invalid string provided"}
end
end
end
Testing in IEx:
iex> StringHelper.valid?("hello world")
true
iex> StringHelper.valid?("")
false
iex> StringHelper.tokenize!("hello world")
["hello", "world"]
iex> StringHelper.tokenize!("")
** (ArgumentError) Invalid string provided
iex> StringHelper.tokenize("hello world")
{:ok, ["hello", "world"]}
iex> StringHelper.tokenize("")
{:error, "Invalid string provided"}
Function names in Elixir:
- Must start with a lowercase letter (a-z) or underscore (_)
- Can contain alphanumeric characters, underscores, and the special ending characters
?
and!
- Conventionally use snake_case (lowercase with underscores)
- Cannot contain other special characters or operators
Common conventions to follow:
- Functions returning booleans should end with
?
(e.g.,empty?
,valid?
,exists?
) - Functions that raise exceptions should end with
!
(e.g.,save!
,fetch!
,decode!
) - Provide both safe (returns tuple) and unsafe (raises exception) versions of important functions
- Use descriptive verb-noun combinations (e.g.,
parse_json
,validate_user
,calculate_total
)
Function Arity
In Elixir, functions are identified by their name and arity (the number of arguments they accept). The combination of name and arity is often written as name/arity
.
defmodule StringHandler do
# These are different functions, even though they share the same name
def process(string), do: String.upcase(string) # process/1
def process(string, options), do: String.upcase(string) <> options[:suffix] # process/2
end
Testing in IEx:
iex> StringHandler.process("hello")
"HELLO"
iex> StringHandler.process("hello", suffix: "!")
"HELLO!"
Functions with the same name but different arity are completely separate functions. When referring to a specific function, it's common to use the name/arity
notation, like String.length/1
or Enum.map/2
.
This naming convention is important when:
- Documenting functions
- Importing or referring to functions from other modules
- Using the function capture syntax (e.g.,
&String.upcase/1
)
Function Clauses and Pattern Matching
One of Elixir's most powerful features is the ability to define multiple function clauses with pattern matching:
defmodule Geometry do
def area({:rectangle, width, height}) do
width * height
end
def area({:circle, radius}) do
:math.pi() * radius * radius
end
def area({:triangle, base, height}) do
(base * height) / 2
end
end
Testing in IEx:
iex> Geometry.area({:rectangle, 4, 5})
20
iex> Geometry.area({:circle, 3})
28.274333882308138
iex> Geometry.area({:triangle, 6, 8})
24.0
The appropriate function clause is selected based on the pattern of the arguments.
Function Clauses Order
The order of function clauses matters, as they are tried from top to bottom:
defmodule ListProcessor do
def count([]), do: 0
def count([_head | tail]), do: 1 + count(tail)
end
Testing in IEx:
iex> ListProcessor.count([])
0
iex> ListProcessor.count([1, 2, 3, 4])
4
If we were to reverse the order of these function clauses, the empty list case would never be reached, resulting in an infinite recursion.
Public vs Private Functions
Elixir provides two keywords for defining functions:
-
def
for public functions that can be called from outside the module -
defp
for private functions that can only be called from within the module
defmodule User do
def register(name, email, password) do
# Call private functions to process the registration
with {:ok, validated_name} <- validate_name(name),
{:ok, validated_email} <- validate_email(email),
{:ok, hashed_password} <- hash_password(password) do
{:ok, %{name: validated_name, email: validated_email, password_hash: hashed_password}}
else
{:error, reason} -> {:error, reason}
end
end
defp validate_name(name) when is_binary(name) and byte_size(name) > 0 do
{:ok, String.trim(name)}
end
defp validate_name(_), do: {:error, "Invalid name"}
defp validate_email(email) when is_binary(email) do
if String.contains?(email, "@") do
{:ok, String.downcase(email)}
else
{:error, "Invalid email format"}
end
end
defp validate_email(_), do: {:error, "Invalid email"}
defp hash_password(password) when is_binary(password) and byte_size(password) >= 8 do
# In a real application, you would use a proper password hashing library
{:ok, :crypto.hash(:sha256, password) |> Base.encode16()}
end
defp hash_password(_), do: {:error, "Password too short"}
end
Testing in IEx:
iex> User.register("Alice", "alice@example.com", "password123")
{:ok, %{
name: "Alice",
email: "alice@example.com",
password_hash: "CBFDAC6008F9CAB4083784CBD1874F76618D2A97316EB8D2BFE65DC35D37D6AB"
}}
iex> User.register("", "invalid-email", "short")
{:error, "Invalid name"}
iex> User.validate_name("Alice")
** (UndefinedFunctionError) function User.validate_name/1 is undefined or private
Private functions (defp
) are only accessible from within the module, promoting encapsulation and ensuring that implementation details remain hidden.
Default Arguments
Elixir supports default arguments for functions, which are used when the caller doesn't provide a value:
defmodule Greeter do
def hello(name, language \\ "en") do
phrase = case language do
"en" -> "Hello"
"es" -> "Hola"
"fr" -> "Bonjour"
"pt" -> "Olá"
_ -> "Hello"
end
"#{phrase}, #{name}!"
end
end
Testing in IEx:
iex> Greeter.hello("Alice")
"Hello, Alice!"
iex> Greeter.hello("Carlos", "es")
"Hola, Carlos!"
iex> Greeter.hello("Pierre", "fr")
"Bonjour, Pierre!"
Multiple Function Clauses with Default Arguments
When using default arguments with multiple function clauses, you need to be careful:
defmodule Messenger do
def send_message(message, options \\ [])
def send_message(message, options) when is_binary(message) do
recipient = Keyword.get(options, :recipient, "everyone")
priority = Keyword.get(options, :priority, "normal")
"Sending '#{message}' to #{recipient} with #{priority} priority"
end
def send_message(messages, options) when is_list(messages) do
Enum.map(messages, &send_message(&1, options))
end
end
When using default arguments with multiple clauses, you need to provide a function head (a function declaration without a body) that specifies the defaults.
Testing in IEx:
iex> Messenger.send_message("Hello")
"Sending 'Hello' to everyone with normal priority"
iex> Messenger.send_message("Urgent update", recipient: "team", priority: "high")
"Sending 'Urgent update' to team with high priority"
iex> Messenger.send_message(["Hello", "How are you?"])
["Sending 'Hello' to everyone with normal priority", "Sending 'How are you?' to everyone with normal priority"]
Guards in Functions
Guards allow you to extend pattern matching with extra conditions:
defmodule NumberClassifier do
def classify(x) when is_integer(x) and x < 0 do
"negative integer"
end
def classify(x) when is_integer(x) and x == 0 do
"zero"
end
def classify(x) when is_integer(x) and x > 0 do
"positive integer"
end
def classify(x) when is_float(x) and x < 0 do
"negative float"
end
def classify(x) when is_float(x) and x == 0.0 do
"zero as float"
end
def classify(x) when is_float(x) and x > 0 do
"positive float"
end
def classify(_) do
"not a number"
end
end
Testing in IEx:
iex> NumberClassifier.classify(-10)
"negative integer"
iex> NumberClassifier.classify(0)
"zero"
iex> NumberClassifier.classify(42)
"positive integer"
iex> NumberClassifier.classify(-3.14)
"negative float"
iex> NumberClassifier.classify(0.0)
"zero as float"
iex> NumberClassifier.classify(2.71)
"positive float"
iex> NumberClassifier.classify("hello")
"not a number"
Guards are limited to a specific set of operations and functions, mainly those that don't have side effects and execute quickly.
Documentation and Typespecs
Good documentation is essential for maintainable code. Elixir provides built-in tools for documenting modules and functions:
defmodule StringUtil do
@moduledoc """
Utility functions for string manipulation.
This module provides a collection of functions for common string
operations not covered by the standard String module.
"""
@doc """
Truncates a string to the specified length and adds an ellipsis.
## Parameters
* `string` - The string to truncate
* `length` - The maximum length before truncation
* `ellipsis` - The string to append after truncation (default: "...")
## Examples
iex> StringUtil.truncate("Hello, world!", 5)
"Hello..."
iex> StringUtil.truncate("Short", 10)
"Short"
iex> StringUtil.truncate("Hello, world!", 5, " [more]")
"Hello [more]"
"""
@spec truncate(String.t(), non_neg_integer(), String.t()) :: String.t()
def truncate(string, length, ellipsis \\ "...") when is_binary(string) do
if String.length(string) > length do
String.slice(string, 0, length) <> ellipsis
else
string
end
end
@doc """
Extracts all hashtags from a string.
Returns a list of hashtags (without the # symbol) found in the string.
## Examples
iex> StringUtil.extract_hashtags("Hello #world from #elixir")
["world", "elixir"]
iex> StringUtil.extract_hashtags("No hashtags here")
[]
"""
@spec extract_hashtags(String.t()) :: [String.t()]
def extract_hashtags(string) when is_binary(string) do
Regex.scan(~r/#(\w+)/, string, capture: :all_but_first)
|> List.flatten()
end
end
The module includes:
-
@moduledoc
for module-level documentation -
@doc
for function-level documentation -
@spec
for type specifications
These annotations not only provide documentation for developers but also enable tooling like ExDoc to generate HTML documentation and dialyzer for static type checking.
Module Attributes
Module attributes in Elixir serve multiple purposes:
- Configuration and constants
- Documentation (as we've seen)
- Temporary storage during compilation
Constants and Configuration
defmodule AppConfig do
@timeout 5000
@default_pool_size 10
@supported_formats [:json, :xml, :csv]
def timeout, do: @timeout
def default_pool_size, do: @default_pool_size
def supported_formats, do: @supported_formats
def format_supported?(format) do
format in @supported_formats
end
end
Testing in IEx:
iex> AppConfig.timeout()
5000
iex> AppConfig.format_supported?(:json)
true
iex> AppConfig.format_supported?(:yaml)
false
Compile-Time Computation
Module attributes can be used for compile-time computations:
defmodule MathConstants do
# These values are calculated once when the module is compiled
@pi :math.pi()
@e :math.exp(1)
@golden_ratio (1 + :math.sqrt(5)) / 2
# A more complex example - calculating prime numbers at compile time
# We wrap the calculation in a function that executes immediately
@primes (fn ->
# Special case for 2 (the only even prime number)
# We combine it with other primes found through filtering
[2 | Enum.filter(3..30, fn n ->
# A number is prime if it's only divisible by 1 and itself
# We only need to check divisors up to the square root of n
limit = :math.sqrt(n) |> trunc()
# If n is divisible by any number from 2 to sqrt(n), it's not prime
Enum.all?(2..limit, fn i -> rem(n, i) != 0 end)
end)]
end).() # The () at the end executes the function during compilation
# These functions just return the pre-computed values
def pi, do: @pi
def e, do: @e
def golden_ratio, do: @golden_ratio
def primes, do: @primes
end
Testing in IEx:
iex> MathConstants.pi()
3.141592653589793
iex> MathConstants.e()
2.718281828459045
iex> MathConstants.golden_ratio()
1.618033988749895
iex> MathConstants.primes()
[2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
The prime numbers computation happens at compile time, not at runtime, improving performance.
Best Practices
Here are some best practices to consider when working with named functions in Elixir:
Keep Functions Small and Focused
Each function should have a single responsibility:
# Not so good: one function doing multiple things
def process_user_data(user) do
# Validate
unless is_map(user) and Map.has_key?(user, :name) do
raise "Invalid user data"
end
# Transform
user = Map.update(user, :name, "", &String.upcase/1)
# Persist
save_to_database(user)
# Return
{:ok, user}
end
# Better: Split into focused functions
def process_user_data(user) do
with {:ok, valid_user} <- validate_user(user),
{:ok, processed_user} <- transform_user(valid_user),
{:ok, saved_user} <- persist_user(processed_user) do
{:ok, saved_user}
end
end
defp validate_user(user) when is_map(user) and is_binary(user[:name]) do
{:ok, user}
end
defp validate_user(_), do: {:error, "Invalid user data"}
defp transform_user(user) do
{:ok, Map.update(user, :name, "", &String.upcase/1)}
end
defp persist_user(user) do
# Logic to save to database
{:ok, user}
end
Use Pattern Matching in Function Definitions
Leverage pattern matching for clearer and more concise code:
# Less clear approach using conditionals
def process_list(list) do
if length(list) == 0 do
:empty
else
:non_empty
end
end
# Better approach using pattern matching
def process_list([]), do: :empty
def process_list(_non_empty_list), do: :non_empty
Use Function Clauses for Different Cases
Split complex logic into multiple function clauses:
defmodule PaymentProcessor do
def process_payment(%{type: "credit_card"} = payment) do
# Credit card specific processing
{:ok, charge_credit_card(payment)}
end
def process_payment(%{type: "bank_transfer"} = payment) do
# Bank transfer specific processing
{:ok, process_bank_transfer(payment)}
end
def process_payment(%{type: "digital_wallet"} = payment) do
# Digital wallet specific processing
{:ok, process_digital_wallet(payment)}
end
def process_payment(_), do: {:error, "Unsupported payment type"}
# Private implementation functions
defp charge_credit_card(payment), do: %{id: generate_id(), status: "charged", payment: payment}
defp process_bank_transfer(payment), do: %{id: generate_id(), status: "pending", payment: payment}
defp process_digital_wallet(payment), do: %{id: generate_id(), status: "completed", payment: payment}
defp generate_id, do: "tx_#{:rand.uniform(1000)}"
end
Use Consistent Return Values
Adopt a consistent pattern for function returns to make error handling easier:
# Consistent return values using {:ok, result} and {:error, reason}
defmodule UserRepository do
def find_by_id(id) when is_integer(id) and id > 0 do
# Simulate database lookup
if id == 42 do
{:ok, %{id: 42, name: "Alice", email: "alice@example.com"}}
else
{:error, "User not found"}
end
end
def find_by_id(_), do: {:error, "Invalid ID"}
def create(user) when is_map(user) do
# Validation and creation logic
if Map.has_key?(user, :name) and Map.has_key?(user, :email) do
{:ok, Map.put(user, :id, :rand.uniform(1000))}
else
{:error, "Invalid user data"}
end
end
def create(_), do: {:error, "Invalid user data"}
def update(id, changes) when is_integer(id) and is_map(changes) do
# Update logic
with {:ok, user} <- find_by_id(id) do
{:ok, Map.merge(user, changes)}
end
end
def update(_, _), do: {:error, "Invalid update parameters"}
end
Document Your Functions
Add appropriate documentation for clarity and maintainability:
defmodule EmailValidator do
@moduledoc """
Provides functions for validating email addresses.
"""
@doc """
Validates an email address format.
Returns `:ok` if the email is valid, or `{:error, reason}` if invalid.
## Examples
iex> EmailValidator.validate("user@example.com")
:ok
iex> EmailValidator.validate("invalid-email")
{:error, "Invalid email format"}
"""
@spec validate(String.t()) :: :ok | {:error, String.t()}
def validate(email) when is_binary(email) do
if Regex.match?(~r/^[^\s]+@[^\s]+\.[^\s]+$/, email) do
:ok
else
{:error, "Invalid email format"}
end
end
def validate(_), do: {:error, "Email must be a string"}
end
Organize Related Functions in Coherent Modules
Group related functions into modules that represent a clear domain concept:
# Too broad - mixing concerns
defmodule Utils do
def format_date(date), do: # ...
def validate_email(email), do: # ...
def calculate_tax(amount), do: # ...
def generate_random_string(length), do: # ...
end
# Better organization
defmodule DateFormatter do
def format(date, format \\ :iso), do: # ...
defp parse_format(format), do: # ...
end
defmodule Validators do
def email(email), do: # ...
def password(password), do: # ...
end
defmodule TaxCalculator do
def calculate(amount, rate), do: # ...
def apply_exemptions(amount, exemptions), do: # ...
end
defmodule StringGenerator do
def random(length, type \\ :alphanumeric), do: # ...
defp character_set(type), do: # ...
end
Conclusion
Named functions are the foundation of Elixir programming, providing structure, organization, and reusability to your code. By leveraging pattern matching, guards, documentation, and module organization, you can create expressive and maintainable codebases.
Key takeaways from this article include:
- Named functions are defined within modules using the
def
anddefp
keywords - Pattern matching in function clauses enables expressive and clean code
- Public functions (
def
) can be called from anywhere, while private functions (defp
) are module-internal - Default arguments provide flexibility and convenience
- Guards extend pattern matching with additional conditions
- Documentation and typespecs improve clarity and enable tooling
- Module attributes provide compile-time constants and configuration
By mastering named functions, you'll be able to design robust and maintainable Elixir applications that leverage the language's functional paradigm effectively.
Tip: When deciding between named and anonymous functions, use named functions for reusable logic and core application functionality, and anonymous functions for short, one-off operations, especially in the context of collection processing.
Further Reading
Next Steps
In the upcoming article, we'll explore Pattern Matching in Functions:
Pattern Matching in Functions
- Advanced pattern matching techniques in function parameters
- Multi-clause functions for different input patterns
- Destructuring complex data structures in function arguments
- Using guards to extend pattern matching capabilities
- Common patterns and idioms using pattern matching in functions
- Best practices for readable and maintainable pattern-matched functions
Pattern matching is a fundamental concept in Elixir that we've already touched on, but the next article will dive deeper into its application specifically in function definitions. You'll learn how to leverage pattern matching to create elegant, expressive, and robust function implementations.