DEV Community

Cover image for Learning Elixir: Structs
João Paulo Abreu
João Paulo Abreu

Posted on

Learning Elixir: Structs

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

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] 
Enter fullscreen mode Exit fullscreen mode

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] 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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" 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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{...} 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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!" 
Enter fullscreen mode Exit fullscreen mode

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]}] 
Enter fullscreen mode Exit fullscreen mode

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]] 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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} 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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} 
Enter fullscreen mode Exit fullscreen mode

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] 
Enter fullscreen mode Exit fullscreen mode

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] 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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} 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 } 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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" } ] 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 }} 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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} 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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="} 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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]} 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 }} 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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] } 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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}} 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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, ...> 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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] } 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

✅ 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 
Enter fullscreen mode Exit fullscreen mode

✅ 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 
Enter fullscreen mode Exit fullscreen mode

✅ 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 
Enter fullscreen mode Exit fullscreen mode

❌ DON'T: Access undefined fields

# Bad - will raise KeyError defmodule BadExample do defstruct [:name] end user = %BadExample{name: "Alice"} # user.undefined_field # ** (KeyError) 
Enter fullscreen mode Exit fullscreen mode

❌ 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"}} 
Enter fullscreen mode Exit fullscreen mode

❌ 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"} 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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

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)