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)