TypedStruct

Build Status hex.pm version Hex Docs License Hex.pm

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:

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:

If you want to add a field in the struct, you must therefore:

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:

Additional typedstruct-only options:

Additional typedrecord-only options:

Field-level options (field/3):

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:

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:

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:

Built-in Integrations

Third-party Plugins

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:

Additional typedstruct-only options:

Additional typedrecord-only options:

Field-level options (field/3):

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:

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:

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

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:

Built-in Integrations

Contributing

Before contributing to this project, please read the CONTRIBUTING.md.

License

This project is licensed under the MIT license.