Structs and Typespecs
Maps are flexible. That's the problem. When you have a domain concept like a User or an Order that should always have a fixed set of fields, a plain map lets you misspell keys, omit required fields, or accidentally merge in foreign data. Structs fix this. They're maps with a guaranteed shape and a name attached.
Typespecs add a second layer: they document the types of fields and function arguments, and tools like Dialyzer can use them to find bugs that pattern matching alone won't catch.
This is where Elixir's dynamic-typed nature meets some of the rigor people miss when coming from TypeScript or Rust. It's not as strict as either, but used well, it catches a real percentage of bugs before they hit production.
defstruct
You define a struct inside a module with defstruct:
defmodule User do
defstruct [:name, :email, :age]
end
That's the minimum. Now you can construct instances:
iex> %User{name: "Ada", email: "ada@example.com", age: 36}
%User{name: "Ada", email: "ada@example.com", age: 36}
Each field defaults to nil if not provided. To set defaults, use a keyword list instead of an atom list:
defmodule User do
defstruct name: "anonymous", email: nil, age: 0, role: :member
end
iex> %User{}
%User{name: "anonymous", email: nil, age: 0, role: :member}
iex> %User{name: "Ada"}
%User{name: "Ada", email: nil, age: 0, role: :member}
To require certain fields at construction time, use @enforce_keys:
defmodule User do
@enforce_keys [:email]
defstruct [:name, :email, :age]
end
iex> %User{name: "Ada"}
** (ArgumentError) the following keys must also be given when building struct User: [:email]
This is the Elixir equivalent of "required field" enforcement. Use it for fields that have no sensible default and must come from the caller.
Working With Structs
Structs are maps under the hood — they have a special __struct__ key holding the module name. This means most map operations work, but with extra safety.
iex> user = %User{email: "ada@example.com", name: "Ada"}
iex> user.name
"Ada"
iex> %{user | name: "Bob"}
%User{name: "Bob", email: "ada@example.com", age: nil}
The pipe-update syntax (%{user | ...}) preserves the struct type. Pattern matching on a struct can be as tight or as loose as you want:
def greeting(%User{name: name}), do: "Hello, #{name}"
# matches only Users where age >= 18
def is_adult?(%User{age: age}) when age >= 18, do: true
def is_adult?(%User{}), do: false
# matches any struct
def name_of(%{__struct__: _, name: name}), do: name
One thing structs don't do: they don't enforce types on field values. %User{age: "not a number"} constructs fine. That's where typespecs come in.
The convention is to put a constructor function in the module if construction has logic:
defmodule User do
@enforce_keys [:email]
defstruct [:name, :email, :age, role: :member]
def new(attrs) do
struct!(__MODULE__, attrs)
end
end
struct!/2 validates required keys and raises on unknown ones. Plain struct/2 returns an empty struct rather than raising, which is rarely what you want.
%ModuleName{} Syntax
The %ModuleName{...} form is how you construct and pattern-match structs. The module name has to be a literal at compile time — you can't dynamically pick a struct type with %some_module{}.
# valid
%User{name: "Ada"}
# also valid — pattern with bound module
def info(%struct{name: name}), do: "#{struct}: #{name}"
That last one is a useful pattern: bind the struct module to a variable so you can use it later. It works for matching on "any struct, capture the type."
@type and @spec
Typespecs are documentation that's also analyzable. They use module attributes @type and @spec.
defmodule User do
@enforce_keys [:email]
defstruct [:name, :email, :age, role: :member]
@type role :: :admin | :member | :guest
@type t :: %__MODULE__{
name: String.t() | nil,
email: String.t(),
age: non_neg_integer() | nil,
role: role()
}
@spec new(map()) :: t()
def new(attrs) do
struct!(__MODULE__, attrs)
end
@spec adult?(t()) :: boolean()
def adult?(%__MODULE__{age: age}) when is_integer(age), do: age >= 18
def adult?(_), do: false
end
A few conventions worth noting:
t()is the standard name for "the main type this module exposes."String.t(),User.t(),DateTime.t()— it's everywhere.__MODULE__resolves to the current module, so you don't have to repeat the name.- Union types use
|.:admin | :member | :guestreads literally as "one of these atoms." - Built-ins:
String.t()(binary that holds a string),integer(),non_neg_integer(),pos_integer(),boolean(),atom(),map(),list(),keyword(),pid(),reference(). nilis a valid type.String.t() | nilis the canonical "optional string."
@spec annotates a function. The convention is one spec per function name (covering all clauses), placed immediately before the first clause.
@spec parse(String.t()) :: {:ok, integer()} | {:error, :invalid}
def parse(input) when is_binary(input) do
case Integer.parse(input) do
{n, ""} -> {:ok, n}
_ -> {:error, :invalid}
end
end
The {:ok, value} | {:error, reason} shape is so common that it has a name in the community: "result tuples." Specs make it explicit which errors are possible.
Dialyzer
Typespecs are documentation by themselves. They become enforcement when you run them through Dialyzer — Erlang's static analysis tool that detects type discrepancies.
Most teams use dialyxir, the Elixir wrapper:
# mix.exs
defp deps do
[
{:dialyxir, "~> 1.4", only: [:dev], runtime: false}
]
end
Then:
mix dialyzer
The first run is slow — Dialyzer has to build a "PLT" (Persistent Lookup Table) of types from the standard library and your dependencies. This takes a few minutes. After that, runs are seconds.
What Dialyzer catches:
- Calling a function with the wrong types.
- Pattern matches that can't succeed because the value's type makes them impossible.
- Code paths that always return
:erroror always raise, given the inputs. - Functions whose declared spec doesn't match what the code can actually return.
@spec greet(String.t()) :: String.t()
def greet(name), do: name + "!" # Dialyzer flags: + on a binary
Dialyzer is "success typing," not full static typing. It's lenient by default — it only complains when it can prove something is wrong, not when it can't prove it's right. This means it won't catch every bug, but it has a low false-positive rate.
Common workflow: typespecs go on every public function in a module, Dialyzer runs in CI, the team treats new warnings as build failures. With this discipline, you catch many of the bugs TypeScript would catch in JavaScript projects.
TypedStruct
Writing the same struct fields twice (in defstruct and in @type t) gets old. The community library typed_struct reduces it:
# mix.exs
{:typed_struct, "~> 0.3"}
defmodule User do
use TypedStruct
typedstruct enforce: true do
field :name, String.t()
field :email, String.t()
field :age, non_neg_integer(), default: 0
field :role, :admin | :member, default: :member
end
end
This generates the defstruct, the @type t, and @enforce_keys from a single declaration. For projects with many domain types, it cuts boilerplate significantly. The trade-off is one more dependency and a slightly less obvious file when reading.
For Phoenix/Ecto projects, schemas already provide types via Ecto.Schema, so TypedStruct is most useful for the non-persisted internal types: events, commands, value objects, response shapes.
Realistic Example
Here's how this typically looks in production code:
defmodule Billing.Invoice do
@moduledoc """
An invoice issued to a customer for a specific period.
"""
@enforce_keys [:id, :customer_id, :amount_cents, :issued_at]
defstruct [
:id,
:customer_id,
:amount_cents,
:issued_at,
:paid_at,
status: :pending,
line_items: []
]
@type status :: :pending | :paid | :void | :overdue
@type line_item :: %{
description: String.t(),
amount_cents: non_neg_integer()
}
@type t :: %__MODULE__{
id: String.t(),
customer_id: String.t(),
amount_cents: non_neg_integer(),
issued_at: DateTime.t(),
paid_at: DateTime.t() | nil,
status: status(),
line_items: [line_item()]
}
@spec mark_paid(t(), DateTime.t()) :: t()
def mark_paid(%__MODULE__{status: :pending} = invoice, paid_at) do
%{invoice | status: :paid, paid_at: paid_at}
end
@spec total(t()) :: non_neg_integer()
def total(%__MODULE__{line_items: items}) do
Enum.reduce(items, 0, fn item, acc -> acc + item.amount_cents end)
end
end
Notice the mark_paid/2 clause only matches pending invoices. If you call it on a paid invoice, you get a FunctionClauseError. That's pattern matching as a domain-rule enforcer — you can't accidentally re-pay a paid invoice.
Common Pitfalls
Forgetting @enforce_keys and silently constructing invalid records. A %User{} with nil everywhere is technically a User, just not a useful one. Mark required fields explicitly.
Using Map.put/3 on a struct. It works, but if you misspell the key, it adds a foreign field rather than raising. Use the %{struct | key: val} form for typo safety.
Writing typespecs that lie. Specs that don't match reality are worse than no specs. Run Dialyzer in CI so they stay honest.
Treating Dialyzer as a full type checker. It's success typing. It misses things real type checkers catch. Don't assume "Dialyzer green" means "no type errors."
Skipping the PLT cache. Each Dialyzer run is fast after the first, but the first is slow. Cache the PLT in CI or your team will hate the typespec habit.
Putting @type t :: ... inside a function or block. Module attributes only work at module level. They're not local annotations.
Confusing String.t() and binary(). String.t() is a binary that holds valid UTF-8 text. binary() is any byte sequence. Use String.t() for text, binary() for raw bytes (image data, network packets).
Key Takeaways
- Structs are maps with a name, default values, and optional required fields.
@enforce_keysenforces required fields at construction. Use it for anything without a sensible default.- The
%{struct | key: val}syntax catches typos. The plainMap.put/3doesn't. - Typespecs use
@type t :: ...for the main module type, and@specfor function signatures. - Dialyzer turns specs into static analysis. Run it in CI.
- TypedStruct removes the duplication between
defstructand@type tif you have many domain types. - Pattern matching on struct fields with guards is the idiomatic way to encode domain rules.