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)