Structs are like blueprints for custom-built houses where each room has a specific purpose and fixed location. Unlike a flexible warehouse space where you can put anything anywhere (like maps), a house blueprint defines exactly which rooms exist - kitchen, living room, bedrooms - and ensures every house built from that blueprint follows the same structure. Think of defstruct [:name, :age, :email]
as an architectural plan that guarantees every "Person house" will have exactly those three rooms, with compile-time checking to ensure you don't accidentally try to build a bathroom where the kitchen should be. While this structure provides safety and predictability, it also creates powerful opportunities for pattern matching and type-safe data modeling that make large Elixir applications maintainable and robust. In this comprehensive article, we'll explore how structs work, when they're the perfect choice for your data modeling needs, and the patterns that make them indispensable for building reliable, maintainable Elixir systems.
Note: The examples in this article use Elixir 1.18.4. While most operations should work across different versions, some functionality might vary.
Table of Contents
- Introduction
- Understanding Structs
- Important Limitations and Warnings
- Defining and Creating Custom Structs
- Structs vs Maps: When to Use Each
- Pattern Matching with Structs for Type Safety
- Updating Structs with Immutable Operations
- Advanced Struct Patterns
- Best Practices
- Conclusion
- Further Reading
- Next Steps
Introduction
Structs in Elixir are specialized maps that provide compile-time guarantees, default values, and type safety for your data structures. They represent the next evolution beyond keyword lists and maps, offering the perfect balance between flexibility and structure for building robust applications.
What makes structs special:
- Compile-time guarantees: Field definitions are checked at compile time
- Type safety: Clear data contracts with enforced structure
- Pattern matching power: Robust matching capabilities for control flow
- Default values: Sensible defaults for all fields
- Access control: Clear boundaries between internal and external data
- Memory efficiency: Optimized internal representation built on maps
Think of structs as data contracts that define exactly what your data should look like:
- User profiles:
%User{name: "Alice", email: "alice@example.com", role: :admin}
- API responses:
%Response{status: 200, body: %{}, headers: []}
- Configuration objects:
%Config{host: "localhost", port: 4000, ssl: false}
Structs excel when you need to:
- Define clear data schemas with fixed fields
- Ensure type safety across module boundaries
- Implement type-safe APIs with clear contracts
- Create maintainable APIs with predictable data structures
- Build complex applications where data integrity matters
- Leverage powerful pattern matching for business logic
Let's explore how they work and why they're essential for serious Elixir development!
Understanding Structs
The Internal Structure
Structs are built on top of maps but with additional compile-time metadata and guarantees:
defmodule User do
defstruct [:name, :email, age: 0, active: true]
end
# Creating a struct
user = %User{name: "Alice", email: "alice@example.com"}
# Under the hood, it's still a map with a special __struct__ key
IO.inspect(user)
# %User{name: "Alice", email: "alice@example.com", age: 0, active: true}
# You can see the __struct__ field
user.__struct__
# User
# It's still a map at runtime
is_map(user) # true
Map.keys(user) # [:__struct__, :active, :age, :email, :name]
Testing in IEx:
iex> defmodule User do
defstruct [:name, :email, age: 0, active: true]
end
{:module, User, <<...>>, %User{name: nil, email: nil, age: 0, active: true}}
iex> user = %User{name: "Alice", email: "alice@example.com"}
%User{name: "Alice", email: "alice@example.com", age: 0, active: true}
iex> user.__struct__
User
iex> is_map(user)
true
iex> Map.keys(user)
[:active, :name, :__struct__, :email, :age]
Key Characteristics
defmodule Product do
defstruct [:id, :name, price: 0.0, available: true, tags: []]
end
# Default values are automatically applied
product = %Product{id: 1, name: "Laptop"}
# %Product{available: true, id: 1, name: "Laptop", price: 0.0, tags: []}
# Field access works like maps
product.name # "Laptop"
product.price # 0.0
# But you can't access undefined fields (compile-time error)
# product.description # ** (KeyError) key :description not found
# Pattern matching on struct type
case product do
%Product{available: true} -> "Product is available"
%Product{available: false} -> "Product is unavailable"
_ -> "Not a product"
end
Testing in IEx:
iex> defmodule Product do
defstruct [:id, :name, price: 0.0, available: true, tags: []]
end
{:module, Product, <<...>>, %Product{id: nil, name: nil, price: 0.0, available: true, tags: []}}
iex> product = %Product{id: 1, name: "Laptop"}
%Product{id: 1, name: "Laptop", price: 0.0, available: true, tags: []}
iex> product.name
"Laptop"
iex> product.price
0.0
iex> case product do
%Product{available: true} -> "Product is available"
%Product{available: false} -> "Product is unavailable"
end
"Product is available"
Memory Optimization
defmodule MemoryExample do
defstruct [:name, :value]
end
# Create multiple structs
struct1 = %MemoryExample{name: "first", value: 1}
struct2 = %MemoryExample{name: "second", value: 2}
# When updating structs, they share key structure in memory
updated = %{struct1 | value: 100}
# The compiler knows the field structure at compile time
# This allows for memory optimizations and better performance
Testing in IEx:
iex> defmodule MemoryExample do
defstruct [:name, :value]
end
{:module, MemoryExample, <<...>>, %MemoryExample{name: nil, value: nil}}
iex> struct1 = %MemoryExample{name: "first", value: 1}
%MemoryExample{name: "first", value: 1}
iex> updated = %{struct1 | value: 100}
%MemoryExample{name: "first", value: 100}
iex> struct1.name == updated.name
true
Important Limitations and Warnings
Critical Differences from Maps
⚠️ WARNING: While structs are built on maps, they have important limitations that developers must understand:
defmodule LimitationDemo do
defstruct [:name, :age]
end
demo = %LimitationDemo{name: "Alice", age: 30}
# ✅ These work (struct-specific access)
demo.name # "Alice"
demo.__struct__ # LimitationDemo
# ❌ These DON'T work (map-specific operations)
# demo[:name] # ** (UndefinedFunctionError) Access behaviour not implemented for LimitationDemo
# Enum.map(demo, fn {k, v} -> {k, v} end) # ** (Protocol.UndefinedError)
# ❌ Cannot add undefined fields
# %{demo | undefined_field: "value"} # ** (KeyError) key :undefined_field not found in struct
# ❌ Cannot use square bracket access
# demo[:name] # Will raise UndefinedFunctionError
Testing in IEx:
iex> demo = %LimitationDemo{name: "Alice", age: 30}
%LimitationDemo{name: "Alice", age: 30}
iex> demo.name
"Alice"
iex> demo[:name]
** (UndefinedFunctionError) function LimitationDemo.fetch/2 is undefined (LimitationDemo does not implement the Access behaviour)
iex> Enum.map(demo, fn {k, v} -> {k, v} end)
** (Protocol.UndefinedError) protocol Enumerable not implemented for %LimitationDemo{...}
Compile-Time vs Runtime Behavior
Important: @enforce_keys
provides compile-time checks, NOT runtime validation:
defmodule EnforceExample do
@enforce_keys [:id]
defstruct [:id, :name]
end
# ✅ This works at compile time
valid_struct = %EnforceExample{id: 1, name: "Alice"}
# ❌ This fails at compile time
# invalid_struct = %EnforceExample{name: "Alice"} # ** (ArgumentError)
# ⚠️ But runtime map conversion can bypass enforcement
map_data = %{name: "Bob"}
converted = struct(EnforceExample, map_data) # Creates struct with id: nil!
# ⚠️ But runtime conversion can create structs with nil required fields
nil_struct = struct(EnforceExample, %{name: "Test"}) # id becomes nil
case nil_struct do
%EnforceExample{} -> "This matches even with nil id!"
end
Testing in IEx:
iex> valid = %EnforceExample{id: 1, name: "Alice"}
%EnforceExample{id: 1, name: "Alice"}
iex> struct(EnforceExample, %{name: "Bob"})
%EnforceExample{id: nil, name: "Bob"}
iex> nil_struct = struct(EnforceExample, %{name: "Test"})
%EnforceExample{id: nil, name: "Test"}
iex> case nil_struct do
%EnforceExample{} -> "This matches!"
end
"This matches!"
Protocol Implementation Implications
Key Point: Structs do NOT implement map protocols by default:
defmodule ProtocolDemo do
defstruct [:data]
end
demo = %ProtocolDemo{data: [1, 2, 3]}
# ❌ These common map operations don't work
# Map.get(demo, :data) # Works, but not recommended
# for {key, value} <- demo, do: {key, value} # ** (Protocol.UndefinedError)
# ✅ Proper access methods
demo.data # [1, 2, 3]
Map.fetch(demo, :data) # {:ok, [1, 2, 3]}
# ✅ Convert to map if you need map operations
map_version = Map.from_struct(demo) # %{data: [1, 2, 3]}
for {key, value} <- map_version, do: {key, value} # [{:data, [1, 2, 3]}]
Testing in IEx:
iex> demo = %ProtocolDemo{data: [1, 2, 3]}
%ProtocolDemo{data: [1, 2, 3]}
iex> demo.data
[1, 2, 3]
iex> for {key, value} <- demo, do: {key, value}
** (Protocol.UndefinedError) protocol Enumerable not implemented for %ProtocolDemo{...}
iex> map_version = Map.from_struct(demo)
%{data: [1, 2, 3]}
iex> for {key, value} <- map_version, do: {key, value}
[data: [1, 2, 3]]
Memory and Performance Considerations
Performance Tip: Understand how struct updates work:
defmodule PerformanceAware do
defstruct [:field1, :field2, :field3, :large_data]
def efficient_update(struct, new_value) do
# ✅ Efficient: Uses update syntax, shares structure
%{struct | field1: new_value}
end
def inefficient_recreation(struct, new_value) do
# ❌ Less efficient: Creates entirely new struct
%__MODULE__{
field1: new_value,
field2: struct.field2,
field3: struct.field3,
large_data: struct.large_data
}
end
end
Safe Conversion Patterns
Best Practice: Always validate when converting between maps and structs:
defmodule SafeConversion do
defstruct [:required_field, :optional_field]
def from_map(map) when is_map(map) do
case Map.fetch(map, :required_field) do
{:ok, _value} ->
{:ok, struct(__MODULE__, map)}
:error ->
{:error, :missing_required_field}
end
end
def from_map(_non_map) do
{:error, :invalid_input}
end
def to_map(%__MODULE__{} = struct) do
Map.from_struct(struct)
end
end
Testing in IEx:
iex> SafeConversion.from_map(%{required_field: "present"})
{:ok, %SafeConversion{required_field: "present", optional_field: nil}}
iex> SafeConversion.from_map(%{optional_field: "only"})
{:error, :missing_required_field}
iex> struct = %SafeConversion{required_field: "test"}
%SafeConversion{required_field: "test", optional_field: nil}
iex> SafeConversion.to_map(struct)
%{required_field: "test", optional_field: nil}
Remember: Structs are specialized tools for type-safe data modeling. Use them when you need compile-time guarantees and clear data contracts, but be aware of their limitations compared to regular maps.
Defining and Creating Custom Structs
Basic Struct Definition
defmodule Person do
# Simple field list - all fields default to nil
defstruct [:first_name, :last_name, :email]
end
defmodule Account do
# Mix of required fields and defaults
defstruct [:username, :email, balance: 0.0, active: true, created_at: nil]
end
defmodule Settings do
# All fields with defaults
defstruct theme: "light", language: "en", notifications: true
end
Testing in IEx:
iex> defmodule Person do
defstruct [:first_name, :last_name, :email]
end
{:module, Person, <<...>>, %Person{first_name: nil, last_name: nil, email: nil}}
iex> %Person{}
%Person{first_name: nil, last_name: nil, email: nil}
iex> %Person{first_name: "Alice", last_name: "Smith"}
%Person{first_name: "Alice", last_name: "Smith", email: nil}
iex> defmodule Settings do
defstruct theme: "light", language: "en", notifications: true
end
{:module, Settings, <<...>>, %Settings{theme: "light", language: "en", notifications: true}}
iex> %Settings{}
%Settings{theme: "light", language: "en", notifications: true}
Enforcing Required Keys
defmodule User do
@enforce_keys [:id, :username]
defstruct [:id, :username, :email, role: :user, active: true]
end
# This works - required keys provided
user = %User{id: 1, username: "alice"}
# %User{id: 1, username: "alice", email: nil, role: :user, active: true}
# This would raise a compile-time error
# %User{username: "alice"} # ** (ArgumentError) missing required keys [:id]
Testing in IEx:
iex> defmodule User do
@enforce_keys [:id, :username]
defstruct [:id, :username, :email, role: :user, active: true]
end
{:module, User, <<...>>, %User{id: nil, username: nil, email: nil, role: :user, active: true}}
iex> %User{id: 1, username: "alice"}
%User{id: 1, username: "alice", email: nil, role: :user, active: true}
iex> %User{username: "alice"}
** (ArgumentError) the following keys must also be given when building struct User: [:id]
Constructor Functions
defmodule Customer do
defstruct [:id, :name, :email, :phone, created_at: nil, updated_at: nil]
# Create a new customer with current timestamp
def new(id, name, email, phone \\ nil) do
now = DateTime.utc_now()
%__MODULE__{
id: id,
name: name,
email: email,
phone: phone,
created_at: now,
updated_at: now
}
end
# Alternative constructor with validation
def create(attrs) do
with {:ok, id} <- validate_id(attrs[:id]),
{:ok, name} <- validate_name(attrs[:name]),
{:ok, email} <- validate_email(attrs[:email]) do
{:ok, new(id, name, email, attrs[:phone])}
end
end
defp validate_id(id) when is_integer(id) and id > 0, do: {:ok, id}
defp validate_id(_), do: {:error, :invalid_id}
defp validate_name(name) when is_binary(name) and byte_size(name) > 0, do: {:ok, name}
defp validate_name(_), do: {:error, :invalid_name}
defp validate_email(email) when is_binary(email) do
if String.contains?(email, "@"), do: {:ok, email}, else: {:error, :invalid_email}
end
defp validate_email(_), do: {:error, :invalid_email}
end
Testing in IEx:
iex> customer = Customer.new(1, "Alice Smith", "alice@example.com")
%Customer{
id: 1,
name: "Alice Smith",
email: "alice@example.com",
phone: nil,
created_at: ~U[2025-08-16 13:25:32.515579Z],
updated_at: ~U[2025-08-16 13:25:32.515579Z]
}
iex> Customer.create(%{id: 1, name: "Bob", email: "bob@example.com"})
{:ok, %Customer{...}}
iex> Customer.create(%{id: -1, name: "Invalid", email: "bad-email"})
{:error, :invalid_id}
Nested Structs
defmodule Address do
defstruct [:street, :city, :state, :zip_code, country: "USA"]
end
defmodule Contact do
defstruct [:name, :email, :address]
def new(name, email, address_attrs \\ %{}) do
address = struct(Address, address_attrs)
%__MODULE__{name: name, email: email, address: address}
end
end
defmodule Company do
defstruct [:name, :contacts, founded_at: nil]
def new(name) do
%__MODULE__{name: name, contacts: []}
end
def add_contact(%__MODULE__{contacts: contacts} = company, contact) do
%{company | contacts: [contact | contacts]}
end
end
Testing in IEx:
iex> address = %Address{street: "123 Main St", city: "Portland", state: "OR", zip_code: "97201"}
%Address{street: "123 Main St", city: "Portland", state: "OR", zip_code: "97201", country: "USA"}
iex> contact = Contact.new("Alice", "alice@example.com", %{street: "123 Main St", city: "Portland"})
%Contact{
name: "Alice",
email: "alice@example.com",
address: %Address{
street: "123 Main St",
city: "Portland",
state: nil,
zip_code: nil,
country: "USA"
}
}
iex> company = Company.new("Tech Corp") |> Company.add_contact(contact)
%Company{
name: "Tech Corp",
contacts: [
%Contact{
name: "Alice",
email: "alice@example.com",
address: %Address{
street: "123 Main St",
city: "Portland",
state: nil,
zip_code: nil,
country: "USA"
}
}
],
founded_at: nil
}
Structs vs Maps: When to Use Each
Decision Framework
Understanding when to use structs versus maps is crucial for maintainable Elixir code:
Use Structs When
# 1. Defining data schemas with fixed fields
defmodule User do
defstruct [:id, :name, :email, role: :user, active: true]
end
# 2. Creating APIs that require type guarantees
defmodule APIResponse do
defstruct [:status, :data, :errors, timestamp: nil]
def success(data) do
%__MODULE__{status: :ok, data: data, errors: [], timestamp: DateTime.utc_now()}
end
def error(errors) do
%__MODULE__{status: :error, data: nil, errors: List.wrap(errors), timestamp: DateTime.utc_now()}
end
end
# 3. Creating type-safe APIs with clear contracts
defmodule UserAPI do
def to_public_format(%User{id: id, name: name, email: email}) do
%{id: id, name: name, email: email}
end
def is_admin?(%User{role: :admin}), do: true
def is_admin?(%User{}), do: false
end
# 4. Complex data modeling with business logic
defmodule Order do
defstruct [:id, :items, :customer_id, :status, total: 0.0, created_at: nil]
def new(customer_id, items) do
%__MODULE__{
id: generate_id(),
customer_id: customer_id,
items: items,
status: :pending,
total: calculate_total(items),
created_at: DateTime.utc_now()
}
end
def can_cancel?(%__MODULE__{status: status}) do
status in [:pending, :confirmed]
end
defp generate_id, do: :crypto.strong_rand_bytes(16) |> Base.encode64()
defp calculate_total(items), do: Enum.sum_by(items, & &1.price)
end
Testing in IEx:
iex> response = APIResponse.success(%{users: [], total: 0})
%APIResponse{
status: :ok,
data: %{total: 0, users: []},
errors: [],
timestamp: ~U[2025-08-16 13:27:22.659534Z]
}
iex> user = %User{id: 1, name: "Alice", email: "alice@example.com"}
%User{
id: 1,
name: "Alice",
email: "alice@example.com",
role: :user,
active: true
}
iex> UserAPI.to_public_format(user)
%{id: 1, name: "Alice", email: "alice@example.com"}
iex> UserAPI.is_admin?(user)
false
Use Maps When
# 1. Dynamic data with unknown keys
defmodule DynamicProcessor do
def process_json(json_string) do
# Parse JSON into a map - unknown structure
Jason.decode!(json_string)
|> handle_dynamic_data()
end
def handle_dynamic_data(data) when is_map(data) do
# Process arbitrary key-value pairs
data
|> Enum.map(fn {key, value} -> {String.upcase(key), value} end)
|> Enum.into(%{})
end
end
# 2. Temporary data transformations
defmodule DataTransformer do
def transform_user_data(users) do
users
|> Enum.map(fn user ->
# Temporary map for transformation
%{
"id" => user.id,
"full_name" => user.name,
"contact" => user.email,
"status" => if(user.active, do: "active", else: "inactive")
}
end)
end
end
# 3. Large datasets with frequent key additions/removals
defmodule Cache do
use GenServer
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, %{}, opts)
end
def put(cache, key, value) do
GenServer.call(cache, {:put, key, value})
end
def get(cache, key) do
GenServer.call(cache, {:get, key})
end
# GenServer callbacks
def handle_call({:put, key, value}, _from, state) do
new_state = Map.put(state, key, value)
{:reply, :ok, new_state}
end
def handle_call({:get, key}, _from, state) do
{:reply, Map.get(state, key), state}
end
end
# 4. Configuration from external sources
defmodule ConfigLoader do
def load_from_env do
# Build configuration map from environment variables
%{
"DATABASE_URL" => System.get_env("DATABASE_URL"),
"REDIS_URL" => System.get_env("REDIS_URL"),
"SECRET_KEY" => System.get_env("SECRET_KEY")
}
|> Enum.reject(fn {_key, value} -> is_nil(value) end)
|> Enum.into(%{})
end
end
Testing in IEx:
iex> dynamic_data = %{"name" => "alice", "age" => 30, "unknown_field" => "value"}
%{"age" => 30, "name" => "alice", "unknown_field" => "value"}
iex> DynamicProcessor.handle_dynamic_data(dynamic_data)
%{"AGE" => 30, "NAME" => "alice", "UNKNOWN_FIELD" => "value"}
iex> defmodule TempUser, do: defstruct [:id, :name, :email, active: true]
{:module, TempUser, <<...>>, %TempUser{id: nil, name: nil, email: nil, active: true}}
iex> users = [%TempUser{id: 1, name: "Alice", email: "alice@example.com"}]
[%TempUser{id: 1, name: "Alice", email: "alice@example.com", active: true}]
iex> DataTransformer.transform_user_data(users)
[
%{
"contact" => "alice@example.com",
"full_name" => "Alice",
"id" => 1,
"status" => "active"
}
]
Conversion Strategies
defmodule ConversionUtils do
# Convert struct to map for JSON serialization
def struct_to_map(%{__struct__: _} = struct) do
struct
|> Map.from_struct()
|> Enum.reject(fn {_key, value} -> is_nil(value) end)
|> Enum.into(%{})
end
# Convert map to struct with validation
def map_to_struct(map, struct_module) when is_map(map) do
try do
struct(struct_module, map)
rescue
_ -> {:error, :invalid_struct_data}
else
result -> {:ok, result}
end
end
# Safe conversion with defaults
def safe_struct_conversion(data, struct_module, defaults \\ %{}) do
clean_data =
data
|> Map.merge(defaults)
|> Enum.filter(fn {key, _value} ->
key in struct_module.__struct__() |> Map.keys()
end)
|> Enum.into(%{})
struct(struct_module, clean_data)
end
end
Testing in IEx:
iex> defmodule TestUser, do: defstruct [:id, :name, :email, role: :user, active: true]
{:module, TestUser, <<...>>, %TestUser{id: nil, name: nil, email: nil, role: :user, active: true}}
iex> user = %TestUser{id: 1, name: "Alice", email: "alice@example.com"}
%TestUser{
id: 1,
name: "Alice",
email: "alice@example.com",
role: :user,
active: true
}
iex> ConversionUtils.struct_to_map(user)
%{active: true, id: 1, name: "Alice", email: "alice@example.com", role: :user}
iex> map_data = %{id: 2, name: "Bob", email: "bob@example.com", invalid_field: "ignored"}
%{id: 2, name: "Bob", email: "bob@example.com", invalid_field: "ignored"}
iex> ConversionUtils.map_to_struct(map_data, TestUser)
{:ok,
%TestUser{
id: 2,
name: "Bob",
email: "bob@example.com",
role: :user,
active: true
}}
Pattern Matching with Structs for Type Safety
Basic Pattern Matching
defmodule UserProcessor do
def process_user(%User{active: true, role: :admin} = user) do
"Processing admin user: #{user.name}"
end
def process_user(%User{active: true, role: :user} = user) do
"Processing regular user: #{user.name}"
end
def process_user(%User{active: false} = user) do
"User #{user.name} is inactive"
end
# Catch non-User structs
def process_user(_other) do
{:error, :invalid_user_type}
end
end
defmodule ResponseHandler do
def handle(%APIResponse{status: :ok, data: data}) do
{:success, data}
end
def handle(%APIResponse{status: :error, errors: errors}) when length(errors) > 0 do
{:error, errors}
end
def handle(%APIResponse{status: status}) do
{:unknown_status, status}
end
# Handle non-APIResponse structs
def handle(_other) do
{:error, :invalid_response_type}
end
end
Testing in IEx:
iex> admin = %User{id: 1, name: "Alice", role: :admin, active: true}
%User{id: 1, name: "Alice", email: nil, role: :admin, active: true}
iex> UserProcessor.process_user(admin)
"Processing admin user: Alice"
iex> inactive_user = %User{id: 2, name: "Bob", active: false}
%User{id: 2, name: "Bob", email: nil, role: :user, active: false}
iex> UserProcessor.process_user(inactive_user)
"User Bob is inactive"
iex> UserProcessor.process_user(%{name: "Not a user struct"})
{:error, :invalid_user_type}
Advanced Pattern Matching with Guards
defmodule OrderProcessor do
def can_process?(%Order{status: :pending, total: total}) when total > 0 do
true
end
def can_process?(%Order{status: :confirmed, total: total}) when total > 0 do
true
end
def can_process?(_order), do: false
def apply_discount(%Order{total: total} = order, percentage)
when percentage > 0 and percentage <= 100 do
discount = total * (percentage / 100)
new_total = total - discount
%{order | total: new_total}
end
def categorize_order(%Order{total: total}) when total >= 1000 do
:premium
end
def categorize_order(%Order{total: total}) when total >= 100 do
:standard
end
def categorize_order(%Order{}) do
:basic
end
end
defmodule PaymentHandler do
def process_payment(%Order{status: :confirmed, total: total} = order, %{type: :credit_card} = payment)
when total > 0 do
# Process credit card payment
case charge_card(payment, total) do
{:ok, transaction_id} ->
{:ok, %{order | status: :paid}, transaction_id}
{:error, reason} ->
{:error, reason}
end
end
def process_payment(%Order{status: :confirmed, total: total} = order, %{type: :bank_transfer})
when total > 0 do
# Process bank transfer
{:ok, %{order | status: :payment_pending}}
end
def process_payment(%Order{status: status}, _payment) when status != :confirmed do
{:error, :order_not_confirmed}
end
def process_payment(%Order{total: total}, _payment) when total <= 0 do
{:error, :invalid_total}
end
defp charge_card(_payment, _amount) do
# Simulate payment processing
if :rand.uniform() > 0.1 do
{:ok, "txn_" <> (:crypto.strong_rand_bytes(8) |> Base.encode64())}
else
{:error, :payment_failed}
end
end
end
Testing in IEx:
iex> order = %Order{id: "order_1", status: :pending, total: 150.0}
%Order{
id: "order_1",
items: nil,
customer_id: nil,
status: :pending,
total: 150.0,
created_at: nil
}
iex> OrderProcessor.can_process?(order)
true
iex> OrderProcessor.categorize_order(order)
:standard
iex> discounted = OrderProcessor.apply_discount(order, 10)
%Order{
id: "order_1",
items: nil,
customer_id: nil,
status: :pending,
total: 135.0,
created_at: nil
}
iex> confirmed_order = %{order | status: :confirmed}
%Order{
id: "order_1",
items: nil,
customer_id: nil,
status: :confirmed,
total: 150.0,
created_at: nil
}
iex> payment = %{type: :credit_card, number: "****-****-****-1234"}
%{type: :credit_card, number: "****-****-****-1234"}
iex> PaymentHandler.process_payment(confirmed_order, payment)
{:ok,
%Order{
id: "order_1",
items: nil,
customer_id: nil,
status: :paid,
total: 150.0,
created_at: nil
}, "txn_hjIdJ6UP6To="}
Struct Validation with Pattern Matching
defmodule UserValidator do
def validate_registration(%User{} = user) do
user
|> validate_required_fields()
|> validate_email_format()
|> validate_role()
|> case do
{:ok, validated_user} -> {:ok, validated_user}
{:error, _} = error -> error
end
end
def validate_registration(_non_user) do
{:error, :invalid_user_struct}
end
defp validate_required_fields(%User{id: nil}), do: {:error, :missing_id}
defp validate_required_fields(%User{name: nil}), do: {:error, :missing_name}
defp validate_required_fields(%User{name: name}) when byte_size(name) == 0, do: {:error, :empty_name}
defp validate_required_fields(user), do: {:ok, user}
defp validate_email_format({:error, _} = error), do: error
defp validate_email_format({:ok, %User{email: nil} = user}), do: {:ok, user}
defp validate_email_format({:ok, %User{email: email} = user}) do
if String.contains?(email, "@") do
{:ok, user}
else
{:error, :invalid_email_format}
end
end
defp validate_role({:error, _} = error), do: error
defp validate_role({:ok, %User{role: role} = user}) when role in [:user, :admin, :moderator] do
{:ok, user}
end
defp validate_role({:ok, %User{role: _invalid_role}}) do
{:error, :invalid_role}
end
end
defmodule StructMatcher do
# Match on multiple struct types
def identify_data(%User{name: name}) do
{:user, name}
end
def identify_data(%Order{id: id}) do
{:order, id}
end
def identify_data(%APIResponse{status: status}) do
{:response, status}
end
def identify_data(other) when is_map(other) do
{:map, Map.keys(other)}
end
def identify_data(_other) do
:unknown
end
# Pattern match with extraction
def extract_identifiers([%User{id: user_id} | rest]) do
[user_id | extract_identifiers(rest)]
end
def extract_identifiers([%Order{id: order_id} | rest]) do
[order_id | extract_identifiers(rest)]
end
def extract_identifiers([_other | rest]) do
extract_identifiers(rest)
end
def extract_identifiers([]) do
[]
end
end
Testing in IEx:
iex> valid_user = %User{id: 1, name: "Alice", email: "alice@example.com"}
%User{
id: 1,
name: "Alice",
email: "alice@example.com",
role: :user,
active: true
}
iex> UserValidator.validate_registration(valid_user)
{:ok,
%User{
id: 1,
name: "Alice",
email: "alice@example.com",
role: :user,
active: true
}}
iex> invalid_user = %User{id: nil, name: "Bob"}
%User{id: nil, name: "Bob", email: nil, role: :user, active: true}
iex> UserValidator.validate_registration(invalid_user)
{:error, :missing_id}
iex> StructMatcher.identify_data(%User{name: "Alice"})
{:user, "Alice"}
iex> StructMatcher.identify_data(%{random: "data"})
{:map, [:random]}
Updating Structs with Immutable Operations
Basic Update Syntax
defmodule ImmutableUpdates do
def demo_basic_updates do
# Create initial struct
user = %User{id: 1, name: "Alice", email: "alice@example.com"}
# Update single field
updated_user = %{user | name: "Alice Smith"}
# Update multiple fields
activated_user = %{user | active: true, role: :admin}
# Original struct is unchanged
IO.inspect({user.name, updated_user.name})
# {"Alice", "Alice Smith"}
{user, updated_user, activated_user}
end
def safe_update(struct, field, value) do
if Map.has_key?(struct, field) do
{:ok, %{struct | field => value}}
else
{:error, :field_not_found}
end
end
def conditional_update(%User{active: true} = user, updates) do
# Only update if user is active
{:ok, Map.merge(user, updates)}
end
def conditional_update(%User{active: false}, _updates) do
{:error, :user_inactive}
end
end
Testing in IEx:
iex> user = %User{id: 1, name: "Alice", email: "alice@example.com"}
%User{
id: 1,
name: "Alice",
email: "alice@example.com",
role: :user,
active: true
}
iex> updated = %{user | name: "Alice Smith", role: :admin}
%User{
id: 1,
name: "Alice Smith",
email: "alice@example.com",
role: :admin,
active: true
}
iex> user.name
"Alice"
iex> updated.name
"Alice Smith"
iex> ImmutableUpdates.safe_update(user, :email, "new@example.com")
{:ok,
%User{
id: 1,
name: "Alice",
email: "new@example.com",
role: :user,
active: true
}}
Functional Update Patterns
defmodule UserService do
def update_profile(%User{} = user, changes) do
user
|> apply_name_change(changes[:name])
|> apply_email_change(changes[:email])
|> apply_role_change(changes[:role])
|> touch_updated_at()
end
defp apply_name_change(user, nil), do: user
defp apply_name_change(user, new_name) when is_binary(new_name) do
%{user | name: String.trim(new_name)}
end
defp apply_email_change(user, nil), do: user
defp apply_email_change(user, new_email) when is_binary(new_email) do
if String.contains?(new_email, "@") do
%{user | email: String.downcase(new_email)}
else
user # Invalid email, don't update
end
end
defp apply_role_change(user, nil), do: user
defp apply_role_change(user, new_role) when new_role in [:user, :admin, :moderator] do
%{user | role: new_role}
end
defp apply_role_change(user, _invalid_role), do: user
defp touch_updated_at(user) do
Map.put(user, :updated_at, DateTime.utc_now())
end
# Batch update with validation
def batch_update(users, update_fn) when is_list(users) and is_function(update_fn, 1) do
users
|> Enum.map(update_fn)
|> Enum.filter(&valid_user?/1)
end
defp valid_user?(%User{id: id, name: name}) when not is_nil(id) and is_binary(name) do
byte_size(name) > 0
end
defp valid_user?(_), do: false
end
defmodule OrderUpdater do
def add_item(%Order{items: items} = order, new_item) do
updated_items = [new_item | items || []]
new_total = calculate_total(updated_items)
%{order | items: updated_items, total: new_total}
end
def remove_item(%Order{items: items} = order, item_id) do
updated_items = Enum.reject(items || [], &(&1.id == item_id))
new_total = calculate_total(updated_items)
%{order | items: updated_items, total: new_total}
end
def apply_discount(%Order{total: total} = order, %{type: :percentage, value: percent})
when percent > 0 and percent <= 100 do
discount_amount = total * (percent / 100)
new_total = max(total - discount_amount, 0)
%{order | total: new_total}
end
def apply_discount(%Order{total: total} = order, %{type: :fixed, value: amount})
when amount > 0 do
new_total = max(total - amount, 0)
%{order | total: new_total}
end
defp calculate_total(items) do
items
|> Enum.sum_by(fn item -> item.price * item.quantity end)
end
end
Testing in IEx:
iex> user = %User{id: 1, name: "alice", email: "ALICE@EXAMPLE.COM"}
%User{
id: 1,
name: "alice",
email: "ALICE@EXAMPLE.COM",
role: :user,
active: true
}
iex> changes = %{name: "Alice Smith ", email: "ALICE.SMITH@EXAMPLE.COM", role: :admin}
%{name: "Alice Smith ", email: "ALICE.SMITH@EXAMPLE.COM", role: :admin}
iex> updated = UserService.update_profile(user, changes)
%{
active: true,
id: 1,
name: "Alice Smith",
__struct__: User,
email: "alice.smith@example.com",
role: :admin,
updated_at: ~U[2025-08-16 13:38:30.171564Z]
}
Complex Update Workflows
defmodule WorkflowUpdater do
# Pipeline-based updates
def process_user_activation(user_id, activation_data) do
with {:ok, user} <- fetch_user(user_id),
{:ok, validated_user} <- validate_activation(user, activation_data),
{:ok, activated_user} <- activate_user(validated_user),
{:ok, enriched_user} <- enrich_user_data(activated_user),
:ok <- notify_activation(enriched_user) do
{:ok, enriched_user}
end
end
defp fetch_user(_user_id) do
# Simulate database fetch
{:ok, %User{id: 1, name: "Alice", active: false}}
end
defp validate_activation(%User{active: true}, _data) do
{:error, :already_active}
end
defp validate_activation(user, %{email_verified: true, phone_verified: true}) do
{:ok, user}
end
defp validate_activation(_user, _data) do
{:error, :incomplete_verification}
end
defp activate_user(user) do
activated = %{user | active: true, role: :user}
{:ok, activated}
end
defp enrich_user_data(user) do
enriched = Map.put(user, :activated_at, DateTime.utc_now())
{:ok, enriched}
end
defp notify_activation(_user) do
# Simulate notification
:ok
end
# Conditional updates with state machine
def transition_order_status(%Order{status: :pending} = order, :confirm) do
{:ok, %{order | status: :confirmed}}
end
def transition_order_status(%Order{status: :confirmed} = order, :ship) do
{:ok, %{order | status: :shipped}}
end
def transition_order_status(%Order{status: :shipped} = order, :deliver) do
{:ok, %{order | status: :delivered}}
end
def transition_order_status(%Order{status: current}, desired) do
{:error, {:invalid_transition, current, desired}}
end
# Nested struct updates
def update_user_address(%Contact{address: address} = contact, address_changes) do
updated_address = Map.merge(address, address_changes)
%{contact | address: updated_address}
end
end
Testing in IEx:
iex> activation_data = %{email_verified: true, phone_verified: true}
%{email_verified: true, phone_verified: true}
iex> WorkflowUpdater.process_user_activation(1, activation_data)
{:ok,
%{
active: true,
id: 1,
name: "Alice",
__struct__: User,
email: nil,
role: :user,
activated_at: ~U[2025-08-16 13:39:28.476339Z]
}}
iex> order = %Order{id: "order_1", status: :pending}
%Order{
id: "order_1",
items: nil,
customer_id: nil,
status: :pending,
total: 0.0,
created_at: nil
}
iex> WorkflowUpdater.transition_order_status(order, :confirm)
{:ok,
%Order{
id: "order_1",
items: nil,
customer_id: nil,
status: :confirmed,
total: 0.0,
created_at: nil
}}
iex> WorkflowUpdater.transition_order_status(order, :ship)
{:error, {:invalid_transition, :pending, :ship}}
Advanced Struct Patterns
Using @derive for Protocol Implementation
# Custom derivation for Inspect protocol - show only public fields
defmodule APIUser do
@derive {Inspect, only: [:id, :name, :email]}
defstruct [:id, :name, :email, :password_hash, :internal_notes]
end
# Hide sensitive fields from inspection
defmodule SecureUser do
@derive {Inspect, except: [:password_hash, :secret_key]}
defstruct [:id, :name, :email, :password_hash, :secret_key]
end
# For libraries that support derivation, you can specify multiple
defmodule Product do
@derive {Inspect, except: [:internal_cost]}
defstruct [:id, :name, :price, :internal_cost, :supplier_id]
end
Testing in IEx:
iex> user = %APIUser{id: 1, name: "Alice", email: "alice@example.com", password_hash: "secret", internal_notes: "VIP"}
#APIUser<id: 1, name: "Alice", email: "alice@example.com", ...>
iex> inspect(user)
"#APIUser<id: 1, name: \"Alice\", email: \"alice@example.com\", ...>"
iex> secure = %SecureUser{id: 1, name: "Alice", password_hash: "secret"}
#SecureUser<id: 1, name: "Alice", email: nil, ...>
Module Alias Pattern
defmodule UserAccount do
alias __MODULE__
defstruct [:id, :username, :email, :profile, created_at: nil]
def new(username, email) do
%UserAccount{
id: generate_id(),
username: username,
email: email,
created_at: DateTime.utc_now()
}
end
def with_profile(%UserAccount{} = account, profile_data) do
%{account | profile: profile_data}
end
defp generate_id do
:crypto.strong_rand_bytes(8) |> Base.encode64()
end
end
defmodule TeamMember do
alias __MODULE__
@enforce_keys [:user_id, :team_id]
defstruct [:user_id, :team_id, :role, :permissions, joined_at: nil]
def new(user_id, team_id, role \\ :member) do
%TeamMember{
user_id: user_id,
team_id: team_id,
role: role,
permissions: default_permissions(role),
joined_at: DateTime.utc_now()
}
end
def promote(%TeamMember{} = member, new_role) do
%{member | role: new_role, permissions: default_permissions(new_role)}
end
defp default_permissions(:admin), do: [:read, :write, :delete, :manage]
defp default_permissions(:moderator), do: [:read, :write, :delete]
defp default_permissions(:member), do: [:read, :write]
defp default_permissions(_), do: [:read]
end
Testing in IEx:
iex> account = UserAccount.new("alice", "alice@example.com")
%UserAccount{
id: "nvL4rQrEyBo=",
username: "alice",
email: "alice@example.com",
profile: nil,
created_at: ~U[2025-08-16 13:42:46.313086Z]
}
iex> member = TeamMember.new(1, 5, :moderator)
%TeamMember{
user_id: 1,
team_id: 5,
role: :moderator,
permissions: [:read, :write, :delete],
joined_at: ~U[2025-08-16 13:43:03.302761Z]
}
iex> promoted = TeamMember.promote(member, :admin)
%TeamMember{
user_id: 1,
team_id: 5,
role: :admin,
permissions: [:read, :write, :delete, :manage],
joined_at: ~U[2025-08-16 13:43:03.302761Z]
}
Best Practices
Do's and Don'ts
✅ DO: Use @enforce_keys
for required fields
# Good - enforces critical fields at compile time
defmodule User do
@enforce_keys [:id, :email]
defstruct [:id, :email, :name, role: :user, active: true]
end
✅ DO: Provide constructor functions
# Good - encapsulates creation logic
defmodule User do
defstruct [:id, :name, :email, created_at: nil]
def new(name, email) do
%__MODULE__{
id: generate_id(),
name: name,
email: email,
created_at: DateTime.utc_now()
}
end
defp generate_id, do: :crypto.strong_rand_bytes(8) |> Base.encode64()
end
✅ DO: Use pattern matching for type safety
# Good - clear, type-safe function definitions
def process_user(%User{active: true} = user) do
# Process active user
end
def process_user(%User{active: false}) do
{:error, :user_inactive}
end
✅ DO: Use struct-specific access methods
# Good - clear, safe field access
def get_user_display_name(%User{name: name}) when is_binary(name) do
String.trim(name)
end
def get_user_display_name(%User{name: nil}) do
"Unknown User"
end
❌ DON'T: Access undefined fields
# Bad - will raise KeyError
defmodule BadExample do
defstruct [:name]
end
user = %BadExample{name: "Alice"}
# user.undefined_field # ** (KeyError)
❌ DON'T: Use structs for dynamic data
# Bad - structs are for fixed schemas
defmodule BadDynamic do
defstruct [] # Empty struct defeats the purpose
end
# Good - use maps for dynamic data
dynamic_data = %{"user_123" => %{name: "Alice"}, "user_456" => %{name: "Bob"}}
❌ DON'T: Ignore compile-time guarantees
# Bad - creating structs manually without validation
user = %{__struct__: User, id: nil, name: "Alice"} # Bypasses @enforce_keys
# Good - use proper struct syntax
user = %User{id: 1, name: "Alice"}
Performance Considerations
defmodule PerformanceExample do
defstruct [:id, :data, :metadata]
# Efficient: Update syntax shares memory structure
def efficient_update(%__MODULE__{} = struct, new_data) do
%{struct | data: new_data}
end
# Less efficient: Creating new struct from scratch
def inefficient_update(%__MODULE__{id: id, metadata: meta}, new_data) do
%__MODULE__{id: id, data: new_data, metadata: meta}
end
# Batch operations for multiple updates
def batch_update(structs, update_fn) when is_function(update_fn, 1) do
Enum.map(structs, update_fn)
end
# Stream for large datasets
def stream_process(structs, process_fn) do
structs
|> Stream.map(process_fn)
|> Stream.filter(&valid?/1)
|> Enum.to_list()
end
defp valid?(%__MODULE__{id: id}) when not is_nil(id), do: true
defp valid?(_), do: false
end
Conclusion
Structs in Elixir represent a powerful evolution in data modeling that bridges the gap between the flexibility of maps and the safety of strongly-typed systems. In this comprehensive article, we've explored the full spectrum of struct capabilities:
- How structs work internally as enhanced maps with compile-time guarantees
- Defining custom structs with required fields, defaults, and constructors
- Strategic decision-making between structs and maps based on use cases
- Leveraging pattern matching for robust type safety and business logic
- Mastering immutable update operations that maintain data integrity
- Advanced patterns including @derive attributes and constructor functions
- Performance considerations and memory optimization techniques
Key takeaways:
- Type safety first: Structs provide compile-time guarantees that prevent entire classes of runtime errors
- Clear data contracts: Well-defined structs serve as documentation and API contracts
- Pattern matching power: Structs enable robust, readable control flow through pattern matching
- Access control: Clear boundaries between internal and external data
- Memory efficient: Update syntax allows memory structure sharing for performance
- Immutable by design: Struct updates create new instances, maintaining data integrity
- Constructor patterns: Custom creation functions encapsulate validation and business logic
Structs demonstrate Elixir's philosophy of making the right choice the easy choice. While maps excel at dynamic data and large datasets, structs shine when you need predictable, well-defined data structures that evolve safely over time. Their integration with pattern matching and the type system makes them indispensable for building maintainable, robust applications.
Understanding structs deeply will transform how you approach data modeling in Elixir. They're not just enhanced maps—they're foundational building blocks for creating systems that are both flexible and reliable, enabling you to build applications that scale gracefully from small scripts to complex distributed systems.
The combination of compile-time safety, runtime performance, and developer ergonomics makes structs one of Elixir's most valuable features for serious application development.
Further Reading
- Elixir Official Documentation - Structs
- Elixir School - Structs
- Programming Elixir by Dave Thomas - Structs and Maps
Next Steps
With a solid understanding of structs, you're ready to explore Binaries and Bitstrings in Elixir. This fundamental data structure is essential for working with raw data, I/O operations, network protocols, and efficient string manipulation at the byte level.
In the next article, we'll explore:
- Understanding binary data representation and bitstrings
- Pattern matching with binaries for data parsing
- Binary operations and manipulation functions
- Real-world applications in file processing and network communication
- Performance considerations when working with binary data
Binaries represent one of Elixir's most powerful features for systems programming and data processing, complementing the high-level data structures we've covered with low-level control when you need it!
Top comments (0)