TypedStruct is a library for defining structs with a type without writing boilerplate code.
NOTE: This is an active fork of the original typedstruct work by Jean-Philippe Cugnet, which seems to be no longer maintained. This version adds type information to Erlang records and makes the project compile under OTP-26 and later.
Rationale
To define a struct in Elixir, you probably want to define three things:
- the struct itself, with default values,
- the list of enforced keys,
- its associated type.
It ends up in something like this:
defmodule Person do
@moduledoc """
A struct representing a person.
"""
@enforce_keys [:name]
defstruct name: nil,
age: nil,
happy?: true,
phone: nil
@typedoc "A person"
@type t() :: %__MODULE__{
name: String.t(),
age: non_neg_integer() | nil,
happy?: boolean(),
phone: String.t() | nil
}
end
In the example above you can notice several points:
- the keys are present in both the
defstructand type definition, - enforced keys must also be written in
@enforce_keys, - if a key has no default value and is not enforced, its type should be nullable.
If you want to add a field in the struct, you must therefore:
- add the key with its default value in the
defstructlist, - add the key with its type in the type definition.
If the field is not optional, you should even add it to @enforce_keys. This is
way too much work for lazy people like me, and moreover it can be error-prone.
It would be way better if we could write something like this:
defmodule Person do
@moduledoc """
A struct representing a person.
"""
use TypedStruct
typedstruct do
@typedoc "A person"
field :name, String.t(), enforce: true
field :age, non_neg_integer()
field :happy?, boolean(), default: true
field :phone, String.t()
end
end
How do I use TypedStruct?
Setup
To use TypedStruct in your project, add this to your Mix dependencies:
{:typedstruct, "~> 0.6"}
If you do not plan to compile modules using TypedStruct at runtime, you can add
runtime: false to the dependency tuple as TypedStruct is only used at build
time.
If you want to avoid mix format putting parentheses on field definitions,
you can add to your .formatter.exs:
[
...,
import_deps: [:typedstruct]
]
General usage
To define a typed struct, use
TypedStruct, then define
your struct within a typedstruct block:
defmodule MyStruct do
# Use TypedStruct to import the typedstruct macro.
use TypedStruct
# Define your struct.
typedstruct do
# Define each field with the field macro.
field :a_string, String.t()
# You can set a default value.
field :string_with_default, String.t(), default: "default"
# You can enforce a field.
field :enforced_field, integer(), enforce: true
end
end
Each field is defined through the
field/2 macro.
To define a record use the typedrecord block:
defmodule Person do
use TypedStruct
typedrecord :person do
@typedoc "A person"
field :name, String.t(),
field :age, non_neg_integer(), default: 0
end
end
Options
All options are listed together here for quick reference.
Top-level options available on both typedstruct and typedrecord:
module: ModuleName- defines the generated struct/record in a submodule.visibility: :public | :private | :opaque- controls generated type visibility.strict: true- requires each field to be enforced or providedefault/default_fn.constructors: true- generates validated constructors.introspection: true- generates helper functions for fields, defaults, required fields, and types (see Introspection helpers).docs: :full | :module_only | :fields_only | false- controls how field docs are merged into module documentation.
Additional typedstruct-only options:
enforce: true- enforces all fields by default unless overridden per field.new: true- generates a simplenew/1constructor (struct(__MODULE__, attrs)).derive: [Protocol1, Protocol2]- passes through@derivebeforedefstruct.opaque: true- deprecated alias forvisibility: :opaque.
Additional typedrecord-only options:
tag: atom()- sets record tag passed toRecord.defrecord/3.opaque: true- deprecated alias forvisibility: :opaque.
Field-level options (field/3):
default: value- static default value.default_fn: &Mod.fun/0- dynamic default used by generated constructors. (defaultanddefault_fnare mutually exclusive.)enforce: true | false- field-level enforce override.doc: "..."- field-level documentation included in generated docs.
Examples
Enforce all keys by default:
defmodule MyStruct do
use TypedStruct
# Enforce keys by default.
typedstruct enforce: true do
# This key is enforced.
field :enforced_by_default, term()
# You can override the default behaviour.
field :not_enforced, term(), enforce: false
# A key with a default value is not enforced.
field :not_enforced_either, integer(), default: 1
end
end
Generate opaque/private types with visibility:
defmodule MyOpaqueStruct do
use TypedStruct
# Generate an opaque type for the struct.
typedstruct visibility: :opaque do
field :name, String.t()
end
end
Define generated code in a submodule:
defmodule MyPrivateStruct do
use TypedStruct
# Generate a private type for the struct.
typedstruct visibility: :private do
field :name, String.t()
end
end
defmodule MyModule do
use TypedStruct
# You now have %MyModule.Struct{}.
typedstruct module: Struct do
field :field, term()
end
end
Introspection helpers
When introspection: true is enabled, TypedStruct generates reflection helpers
at compile time.
For typedstruct, the following functions are generated on the struct module:
__fields__/0- ordered list of field names.__required__/0- ordered list of enforced field names.__defaults__/0- map of static defaults fromdefault:.__dynamic_defaults__/0- ordered list of fields usingdefault_fn:.__types__/0- internal list used to build the generated typespec.
Example:
defmodule User do
use TypedStruct
typedstruct introspection: true do
field :id, integer(), enforce: true
field :name, String.t(), default: "anonymous"
field :token, String.t(), default_fn: &UserDefaults.token/0
end
end
User.__fields__()
# => [:id, :name, :token]
User.__required__()
# => [:id]
User.__defaults__()
# => %{id: nil, name: "anonymous", token: nil}
User.__dynamic_defaults__()
# => [:token]
For typedrecord :person, the same information is exposed with record-prefixed
function names to avoid collisions when multiple records are defined in one
module:
person_fields/0person_required/0person_defaults/0person_dynamic_defaults/0person_types/0
Example:
defmodule People do
use TypedStruct
typedrecord :person, introspection: true do
field :id, integer(), enforce: true
field :name, String.t(), default: "anonymous"
end
end
People.person_fields()
# => [:id, :name]
These helpers are intended for tooling, schema inspection, and diagnostics.
If you need runtime validation/construction as well, combine with
constructors: true.
Documentation
TypedStruct supports comprehensive documentation through multiple attributes:
Type Documentation with @typedoc
Add a @typedoc attribute in the typedstruct block to document the type:
typedstruct do
@typedoc "A typed struct"
field :a_string, String.t()
field :an_int, integer()
end
Module Documentation
You can also document the entire module and submodules:
typedstruct module: MyStruct do
@moduledoc "A submodule with a typed struct."
@typedoc "A typed struct in a submodule"
field :a_string, String.t()
field :an_int, integer()
end
Field Documentation
Document individual fields in two ways:
1. Using the doc: option:
typedstruct do
field :a_string, String.t(), doc: "A string field"
field :an_int, integer(), doc: "An integer field"
end
2. Using the @doc attribute (applies to the next field):
typedstruct do
@doc "A string field"
field :a_string, String.t()
@doc "An integer field"
field :an_int, integer()
end
The @doc attribute is automatically cleared after each field, ensuring it only documents the intended field. This approach is useful when you want to keep documentation close to the field definition.
TypedStruct will append a generated ## Fields section to the module documentation when running mix docs for both typedstruct and typedrecord blocks:
defmodule User do
use TypedStruct
@moduledoc "Represents an account user."
typedstruct do
field :name, String.t(), doc: "Display name shown in the UI."
field :email, String.t(), doc: "Primary email address."
field :active, boolean()
end
end
Only fields with a doc value are included in the generated ## Fields
section.
You can combine docs and constructors options, for example:
defmodule User do
use TypedStruct
typedstruct constructors: true, strict: true, introspection: true do
field :id, integer(), enforce: true, doc: "User id"
field :name, String.t(), default: "anonymous", doc: "Display name"
field :token, String.t(), default_fn: &UserDefaults.token/0
end
end
Plugins
It is possible to extend the scope of TypedStruct by using its plugin interface,
as described in
TypedStruct.Plugin.
For instance, to automatically generate lenses with the
Lens library, you can use
TypedStructLens and do:
defmodule MyStruct do
use TypedStruct
typedstruct do
plugin TypedStructLens
field :a_field, String.t()
field :other_field, atom()
end
@spec change(t()) :: t()
def change(data) do
# a_field/0 is generated by TypedStructLens.
lens = a_field()
put_in(data, [lens], "Changed")
end
end
Presently plugins are not supported by the typedrecord block.
Default Plugins
You can specify default plugins when using TypedStruct, which will be automatically
injected into all typedstruct and typedrecord definitions. This is useful for
creating wrapper modules that provide preset plugins:
defmodule MyApp.Type do
defmacro __using__(_) do
quote do
use TypedStruct, plugins: [MyApp.TypedStruct.Plugin.EctoTypes]
end
end
end
defmodule MyApp.User do
use MyApp.Type
typedstruct do
field :id, integer(), enforce: true
field :name, String.t(), ecto_type: :string
end
end
Now MyApp.User automatically has the EctoTypes plugin injected without needing
to manually call plugin MyApp.TypedStruct.Plugin.EctoTypes in the typedstruct block.
Ecto Integration
TypedStruct provides optional integration with Ecto through the TypedStruct.Ecto module, enabling you to define Ecto schemas with automatic type inference:
defmodule User do
use TypedStruct.Ecto
typedschema "users" do
field :id, :id
field :email, :string, enforce: true
field :name, :string
field :age, :integer
belongs_to :company, Company
has_many :posts, Post
timestamps()
end
end
The typedschema macro automatically infers Elixir types from Ecto field types, generates proper types for associations, supports polymorphic embeds, and enables type overrides with :: syntax.
For comprehensive guide with examples, see ecto.md:
- Field types and associations
- Embedded and polymorphic schemas
- Type overrides and custom types
- Real-world examples (e-commerce, CMS, notifications)
- Validation patterns
- Comparison with typed_ecto_schema
Built-in Integrations
TypedStruct.Ecto– Optional module providingtypedschema/2macro for seamless Ecto schema integration. Requires Ecto to be installed.TypedStruct.Plugin.EctoSchema– Reference plugin showing how to build plugins for external libraries.
Third-party Plugins
typedstruct_lens– Integration with the Lens library.typedstruct_legacy_reflection– Re-enables the legacy reflection functions from TypedStruct 0.1.x.typed_struct_ctor– Adds validating (newandfrom) constructor functions.Try the macro out in real time without having to install or write any of your own code. All you need is a running instance of Livebook
typed_ecto_schema– Specialized library for Ecto schemas with automatic typespec generation for associations, timestamps, and other Ecto-specific features.
This list is not meant to be exhaustive, please search for “typedstruct” on hex.pm for other results. If you want your plugin to appear here, please open an issue.
What do I get?
When defining an empty typedstruct block:
defmodule Example do
use TypedStruct
typedstruct do
end
end
you get an empty struct with its module type t():
defmodule Example do
@enforce_keys []
defstruct []
@type t() :: %__MODULE__{}
end
Each field call adds information to the struct, @enforce_keys and the type
t().
A field with no options adds the name to the defstruct list, with nil as
default. The type itself is made nullable:
defmodule Example do
use TypedStruct
typedstruct do
field :name, String.t()
end
end
becomes:
defmodule Example do
@enforce_keys []
defstruct name: nil
@type t() :: %__MODULE__{
name: String.t() | nil
}
end
The default option adds the default value to the defstruct:
field :name, String.t(), default: "John Smith"
# Becomes
defstruct name: "John Smith"
When set to true, the enforce option enforces the key by adding it to the
@enforce_keys attribute.
field :name, String.t(), enforce: true
# Becomes
@enforce_keys [:name]
defstruct name: nil
In both cases, the type has no reason to be nullable anymore by default. In one
case the field is filled with its default value and not nil, and in the other
case it is enforced. Both options would generate the following type:
@type t() :: %__MODULE__{
name: String.t() # Not nullable
}
Passing opaque: true replaces @type with @opaque in the struct type
specification:
typedstruct opaque: true do
field :name, String.t()
end
generates the following type:
@opaque t() :: %__MODULE__{
name: String.t()
}
When passing module: ModuleName, the whole typedstruct block is wrapped in a
module definition. This way, the following definition:
defmodule MyModule do
use TypedStruct
typedstruct module: Struct do
field :field, term()
end
end
becomes:
defmodule MyModule do
defmodule Struct do
@enforce_keys []
defstruct field: nil
@type t() :: %__MODULE__{
field: term() | nil
}
end
end
To define a typed record, the following definition of the typedrecord:
defmodule Person do
use TypedStruct
typedrecord :person do
@typedoc "A person"
field :name, String.t()
field :age, non_neg_integer(), default: 0
end
end
becomes:
defmodule Person do
use Record
Record.defrecord(:person, name: nil, age: 0)
@type person :: {:person, String.t()|nil, non_neg_integer()}
end
Usage
General usage
To define a typed struct, use
TypedStruct, then define
your struct within a typedstruct block:
defmodule MyStruct do
# Use TypedStruct to import the typedstruct macro.
use TypedStruct
# Define your struct.
typedstruct do
# Define each field with the field macro.
field :a_string, String.t()
# You can set a default value.
field :string_with_default, String.t(), default: "default"
# You can enforce a field.
field :enforced_field, integer(), enforce: true
end
end
Each field is defined through the
field/2 macro.
To define a record use the typedrecord block:
defmodule Person do
use TypedStruct
typedrecord :person do
@typedoc "A person"
field :name, String.t(),
field :age, non_neg_integer(), default: 0
end
end
Options
All options are listed together here for quick reference.
Top-level options available on both typedstruct and typedrecord:
module: ModuleName- defines the generated struct/record in a submodule.visibility: :public | :private | :opaque- controls generated type visibility.strict: true- requires each field to be enforced or providedefault/default_fn.constructors: true- generates validated constructors.introspection: true- generates helper functions for fields, defaults, required fields, and types (see Introspection helpers).docs: :full | :module_only | :fields_only | false- controls how field docs are merged into module documentation.
Additional typedstruct-only options:
enforce: true- enforces all fields by default unless overridden per field.new: true- generates a simplenew/1constructor (struct(__MODULE__, attrs)).derive: [Protocol1, Protocol2]- passes through@derivebeforedefstruct.opaque: true- deprecated alias forvisibility: :opaque.
Additional typedrecord-only options:
tag: atom()- sets record tag passed toRecord.defrecord/3.opaque: true- deprecated alias forvisibility: :opaque.
Field-level options (field/3):
default: value- static default value.default_fn: &Mod.fun/0- dynamic default used by generated constructors. (defaultanddefault_fnare mutually exclusive.)enforce: true | false- field-level enforce override.doc: "..."- field-level documentation included in generated docs.
Examples
Enforce all keys by default:
defmodule MyStruct do
use TypedStruct
# Enforce keys by default.
typedstruct enforce: true do
# This key is enforced.
field :enforced_by_default, term()
# You can override the default behaviour.
field :not_enforced, term(), enforce: false
# A key with a default value is not enforced.
field :not_enforced_either, integer(), default: 1
end
end
Generate opaque/private types with visibility:
defmodule MyOpaqueStruct do
use TypedStruct
# Generate an opaque type for the struct.
typedstruct visibility: :opaque do
field :name, String.t()
end
end
Define generated code in a submodule:
defmodule MyPrivateStruct do
use TypedStruct
# Generate a private type for the struct.
typedstruct visibility: :private do
field :name, String.t()
end
end
defmodule MyModule do
use TypedStruct
# You now have %MyModule.Struct{}.
typedstruct module: Struct do
field :field, term()
end
end
Introspection helpers
When introspection: true is enabled, TypedStruct generates reflection helpers
at compile time.
For typedstruct, the following functions are generated on the struct module:
__fields__/0- ordered list of field names.__required__/0- ordered list of enforced field names.__defaults__/0- map of static defaults fromdefault:.__dynamic_defaults__/0- ordered list of fields usingdefault_fn:.__types__/0- internal list used to build the generated typespec.
Example:
defmodule User do
use TypedStruct
typedstruct introspection: true do
field :id, integer(), enforce: true
field :name, String.t(), default: "anonymous"
field :token, String.t(), default_fn: &UserDefaults.token/0
end
end
User.__fields__()
# => [:id, :name, :token]
User.__required__()
# => [:id]
User.__defaults__()
# => %{id: nil, name: "anonymous", token: nil}
User.__dynamic_defaults__()
# => [:token]
For typedrecord :person, the same information is exposed with record-prefixed
function names to avoid collisions when multiple records are defined in one
module:
person_fields/0person_required/0person_defaults/0person_dynamic_defaults/0person_types/0
Example:
defmodule People do
use TypedStruct
typedrecord :person, introspection: true do
field :id, integer(), enforce: true
field :name, String.t(), default: "anonymous"
end
end
People.person_fields()
# => [:id, :name]
These helpers are intended for tooling, schema inspection, and diagnostics.
If you need runtime validation/construction as well, combine with
constructors: true.
Documentation
TypedStruct supports comprehensive documentation through multiple attributes:
Type Documentation with @typedoc
Add a @typedoc attribute in the typedstruct block to document the type:
typedstruct do
@typedoc "A typed struct"
field :a_string, String.t()
field :an_int, integer()
end
Module Documentation
You can also document the entire module and submodules:
typedstruct module: MyStruct do
@moduledoc "A submodule with a typed struct."
@typedoc "A typed struct in a submodule"
field :a_string, String.t()
field :an_int, integer()
end
Field Documentation
Document individual fields in two ways:
1. Using the doc: option:
typedstruct do
field :a_string, String.t(), doc: "A string field"
field :an_int, integer(), doc: "An integer field"
end
2. Using the @doc attribute (applies to the next field):
typedstruct do
@doc "A string field"
field :a_string, String.t()
@doc "An integer field"
field :an_int, integer()
end
The @doc attribute is automatically cleared after each field, ensuring it only documents the intended field. This approach is useful when you want to keep documentation close to the field definition.
TypedStruct will append a generated ## Fields section to the module documentation when running mix docs for both typedstruct and typedrecord blocks:
defmodule User do
use TypedStruct
@moduledoc "Represents an account user."
typedstruct do
field :name, String.t(), doc: "Display name shown in the UI."
field :email, String.t(), doc: "Primary email address."
field :active, boolean()
end
end
Only fields with a doc value are included in the generated ## Fields
section.
You can combine docs and constructors options, for example:
defmodule User do
use TypedStruct
typedstruct constructors: true, strict: true, introspection: true do
field :id, integer(), enforce: true, doc: "User id"
field :name, String.t(), default: "anonymous", doc: "Display name"
field :token, String.t(), default_fn: &UserDefaults.token/0
end
end
Plugins
It is possible to extend the scope of TypedStruct by using its plugin interface,
as described in
TypedStruct.Plugin.
For instance, to automatically generate lenses with the
Lens library, you can use
TypedStructLens and do:
defmodule MyStruct do
use TypedStruct
typedstruct do
plugin TypedStructLens
field :a_field, String.t()
field :other_field, atom()
end
@spec change(t()) :: t()
def change(data) do
# a_field/0 is generated by TypedStructLens.
lens = a_field()
put_in(data, [lens], "Changed")
end
end
Presently plugins are not supported by the typedrecord block.
Default Plugins
You can specify default plugins when using TypedStruct, which will be automatically
injected into all typedstruct and typedrecord definitions. This is useful for
creating wrapper modules that provide preset plugins:
defmodule MyApp.Type do
defmacro __using__(_) do
quote do
use TypedStruct, plugins: [MyApp.TypedStruct.Plugin.EctoTypes]
end
end
end
defmodule MyApp.User do
use MyApp.Type
typedstruct do
field :id, integer(), enforce: true
field :name, String.t(), ecto_type: :string
end
end
Now MyApp.User automatically has the EctoTypes plugin injected without needing
to manually call plugin MyApp.TypedStruct.Plugin.EctoTypes in the typedstruct block.
Third-party Plugins
typedstruct_lens– Integration with the Lens library.typedstruct_legacy_reflection– Re-enables the legacy reflection functions from TypedStruct 0.1.x.typed_struct_ctor– Adds validating (newandfrom) constructor functions.Try the macro out in real time without having to install or write any of your own code. All you need is a running instance of Livebook
typed_ecto_schema– Specialized library for Ecto schemas with automatic typespec generation for associations, timestamps, and other Ecto-specific features.
This list is not meant to be exhaustive, please search for “typedstruct” on hex.pm for other results. If you want your plugin to appear here, please open an issue.
Ecto Integration
TypedStruct provides optional integration with Ecto through the TypedStruct.Ecto module, enabling you to define Ecto schemas with automatic type inference:
defmodule User do
use TypedStruct.Ecto
typedschema "users" do
field :id, :id
field :email, :string, enforce: true
field :name, :string
field :age, :integer
belongs_to :company, Company
has_many :posts, Post
timestamps()
end
end
The typedschema macro automatically infers Elixir types from Ecto field types, generates proper types for associations, supports polymorphic embeds, and enables type overrides with :: syntax.
For comprehensive guide with examples, see TypedStruct.Ecto Guide:
- Field types and associations
- Embedded and polymorphic schemas
- Type overrides and custom types
- Real-world examples (e-commerce, CMS, notifications)
- Validation patterns
- Comparison with typed_ecto_schema
Built-in Integrations
TypedStruct.Ecto– Optional module providingtypedschema/2macro for seamless Ecto schema integration. Requires Ecto to be installed.TypedStruct.Plugin.EctoSchema– Reference plugin showing how to build plugins for external libraries.
Related libraries
- Domo: a library to validate structs that
define a
t()type, like the one generated byTypedStruct. - TypedEctoSchema: a library
that provides a DSL on top of
Ecto.Schemato achieve the same result asTypedStruct, withEcto.
Contributing
Before contributing to this project, please read the CONTRIBUTING.md.
License
This project is licensed under the MIT license.