TypedStructor
March 7, 2026 ยท View on GitHub
TypedStructor eliminates the boilerplate of defining Elixir structs, type specs, and enforced keys separately. Define them once, keep them in sync automatically.
Before -- three declarations that must stay in sync manually:
defmodule User do
@enforce_keys [:id]
defstruct [:id, :name, :age]
@type t() :: %__MODULE__{
id: pos_integer(),
name: String.t() | nil,
age: non_neg_integer() | nil
}
end
After -- a single source of truth:
defmodule User do
use TypedStructor
typed_structor do
field :id, pos_integer(), enforce: true
field :name, String.t()
field :age, non_neg_integer()
end
end
Feature Highlights
- Single definition -- struct, type spec, and
@enforce_keysgenerated from one block - Nullable by default -- unenforced fields without defaults automatically include
| nil - Fine-grained null control -- override nullability per-field or per-block with the
:nulloption - Opaque and custom types -- generate
@opaque,@typep, or rename the type fromt() - Type parameters -- define generic/parametric types
- Multiple definers -- supports structs, exceptions, and Erlang records
- Plugin system -- extend behavior at compile time with composable plugins
- Nested modules -- define structs in submodules with the
:moduleoption
Installation
Add :typed_structor to your dependencies in mix.exs:
def deps do
[
{:typed_structor, "~> 0.6"}
]
end
Formatter Setup {: .tip}
Add
:typed_structorto your.formatter.exsfor proper indentation:[ import_deps: [..., :typed_structor], inputs: [...] ]
Getting Started
Use typed_structor blocks to define fields with their types:
defmodule User do
use TypedStructor
typed_structor do
field :id, pos_integer(), enforce: true # Required, never nil
field :name, String.t() # Optional, nullable
field :role, String.t(), default: "user" # Has default, not nullable
end
end
Nullability Rules
The interaction between :enforce, :default, and :null determines whether a field's type includes nil:
:default | :enforce | :null | Type includes nil? |
|---|---|---|---|
unset | false | true | yes |
unset | false | false | no |
set | - | - | no |
| - | true | - | no |
You can set :null at the block level to change the default for all fields:
typed_structor null: false do
field :id, integer() # Not nullable
field :email, String.t() # Not nullable
field :phone, String.t(), null: true # Override: nullable
end
Options
Opaque Types
Use type_kind: :opaque to hide implementation details:
typed_structor type_kind: :opaque do
field :secret, String.t()
end
# Generates: @opaque t() :: %__MODULE__{...}
Custom Type Names
Override the default t() type name:
typed_structor type_name: :user_data do
field :id, pos_integer()
end
# Generates: @type user_data() :: %__MODULE__{...}
Type Parameters
Create generic types with parameter/1:
typed_structor do
parameter :value_type
parameter :error_type
field :value, value_type
field :error, error_type
end
# Generates: @type t(value_type, error_type) :: %__MODULE__{...}
Nested Modules
Define structs in submodules:
defmodule User do
use TypedStructor
typed_structor module: Profile do
field :email, String.t(), enforce: true
field :bio, String.t()
end
end
# Creates User.Profile with its own struct and type
Plugins
Extend TypedStructor's behavior with plugins that run at compile time:
typed_structor do
plugin Guides.Plugins.Accessible
field :id, pos_integer()
field :name, String.t()
end
See the Plugin Guides for examples and instructions on writing your own.
Documentation
Add @typedoc inside the block, and @moduledoc at the module level as usual:
defmodule User do
@moduledoc "User account data"
use TypedStructor
typed_structor do
@typedoc "A user with authentication details"
field :id, pos_integer()
field :name, String.t()
end
end
Advanced Usage
Exceptions
Define typed exceptions with automatic __exception__ handling:
defmodule HTTPException do
use TypedStructor
typed_structor definer: :defexception, enforce: true do
field :status, non_neg_integer()
field :message, String.t()
end
@impl Exception
def message(%__MODULE__{status: status, message: msg}) do
"HTTP #{status}: #{msg}"
end
end
Records
Create Erlang-compatible records:
defmodule UserRecord do
use TypedStructor
typed_structor definer: :defrecord, record_name: :user do
field :name, String.t(), enforce: true
field :age, pos_integer(), enforce: true
end
end
Integration with Other Libraries
Use define_struct: false to skip struct generation when another library defines the struct:
defmodule User do
use TypedStructor
typed_structor define_struct: false do
field :email, String.t(), enforce: true
use Ecto.Schema
@primary_key false
schema "users" do
Ecto.Schema.field(:email, :string)
end
end
end
This generates only the type spec while letting the other library handle the struct definition.
For full Ecto integration with typed fields, see EctoTypedSchema -- a companion library built on TypedStructor.
Learn More
- HexDocs -- full API reference and guides
- Plugin Guides -- build and use plugins
- Changelog -- release history