defmodule TypedStruct do @moduledoc """ TypedStruct is a library for defining structs with a type without writing boilerplate code. ## 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 `defstruct` and 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 `defstruct` list, * 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 Thanks to TypedStruct, this is now possible :) ## Usage ### Setup To use TypedStruct in your project, add this to your Mix dependencies: {:typed_struct, "~> #{Mix.Project.config()[:version]}"} 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: [:typed_struct] ] ### 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. ### Options If you want to enforce all the keys by default, you can do: 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 You can also generate an opaque type for the struct: defmodule MyOpaqueStruct do use TypedStruct # Generate an opaque type for the struct. typedstruct opaque: true do field :name, String.t() end end If you often define submodules containing only a struct, you can avoid boilerplate code: defmodule MyModule do use TypedStruct # You now have %MyModule.Struct{}. typedstruct module: Struct do field :field, term() end end ### Documentation To add a `@typedoc` to the struct type, just add the attribute in the `typedstruct` block: typedstruct do @typedoc "A typed struct" field :a_string, String.t() field :an_int, integer() end You can also document submodules this way: 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 ### 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](https://github.com/obrok/lens) library, you can use [`TypedStructLens`](https://github.com/ejpcmac/typed_struct_lens) 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 ## 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 """ @doc false defmacro __using__(_) do quote do import TypedStruct, only: [typedstruct: 1, typedstruct: 2] end end @doc """ Defines a typed struct. Inside a `typedstruct` block, each field is defined through the `field/2` macro. ## Options * `enforce` - if set to true, sets `enforce: true` to all fields by default. This can be overridden by setting `enforce: false` or a default value on individual fields. * `opaque` - if set to true, creates an opaque type for the struct. * `module` - if set, creates the struct in a submodule named `module`. ## Examples defmodule MyStruct do use TypedStruct typedstruct do field :field_one, String.t() field :field_two, integer(), enforce: true field :field_three, boolean(), enforce: true field :field_four, atom(), default: :hey end end The following is an equivalent using the *enforce by default* behaviour: defmodule MyStruct do use TypedStruct typedstruct enforce: true do field :field_one, String.t(), enforce: false field :field_two, integer() field :field_three, boolean() field :field_four, atom(), default: :hey end end You can create the struct in a submodule instead: defmodule MyModule do use TypedStruct typedstruct, module: Struct do field :field_one, String.t() field :field_two, integer(), enforce: true field :field_three, boolean(), enforce: true field :field_four, atom(), default: :hey end end """ defmacro typedstruct(opts \\ [], do: block) do if is_nil(opts[:module]) do quote do Module.eval_quoted( __ENV__, TypedStruct.__typedstruct__( unquote(Macro.escape(block)), unquote(opts) ) ) end else quote do defmodule unquote(opts[:module]) do Module.eval_quoted( __ENV__, TypedStruct.__typedstruct__( unquote(Macro.escape(block)), unquote(opts) ) ) end end end end @doc false def __typedstruct__(block, opts) do quote do Module.register_attribute(__MODULE__, :ts_plugins, accumulate: true) Module.register_attribute(__MODULE__, :ts_fields, accumulate: true) Module.register_attribute(__MODULE__, :ts_types, accumulate: true) Module.register_attribute(__MODULE__, :ts_enforce_keys, accumulate: true) Module.put_attribute(__MODULE__, :ts_enforce?, unquote(!!opts[:enforce])) # Create a scope to avoid leaks. (fn -> import TypedStruct unquote(block) end).() @enforce_keys @ts_enforce_keys defstruct @ts_fields TypedStruct.__type__(@ts_types, unquote(opts)) Enum.each(@ts_plugins, fn {plugin, plugin_opts} -> if {:after_definition, 1} in plugin.__info__(:functions) do Module.eval_quoted(__MODULE__, plugin.after_definition(plugin_opts)) end end) Module.delete_attribute(__MODULE__, :ts_enforce?) Module.delete_attribute(__MODULE__, :ts_enforce_keys) Module.delete_attribute(__MODULE__, :ts_types) Module.delete_attribute(__MODULE__, :ts_plugins) end end @doc false defmacro __type__(types, opts) do if Keyword.get(opts, :opaque, false) do quote bind_quoted: [types: types] do @opaque t() :: %__MODULE__{unquote_splicing(types)} end else quote bind_quoted: [types: types] do @type t() :: %__MODULE__{unquote_splicing(types)} end end end @doc """ Registers a plugin for the currently defined struct. ## Example typedstruct do plugin MyPlugin field :a_field, String.t() end For more information on how to define your own plugins, please see `TypedStruct.Plugin`. To use a third-party plugin, please refer directly to its documentation. """ defmacro plugin(plugin, opts \\ []) do quote do Module.put_attribute( __MODULE__, :ts_plugins, {unquote(plugin), unquote(opts)} ) require unquote(plugin) unquote(plugin).init(unquote(opts)) end end @doc """ Defines a field in a typed struct. ## Example # A field named :example of type String.t() field :example, String.t() ## Options * `default` - sets the default value for the field * `enforce` - if set to true, enforces the field and makes its type non-nullable """ defmacro field(name, type, opts \\ []) do quote do TypedStruct.__field__( __MODULE__, unquote(name), unquote(Macro.escape(type)), unquote(opts) ) Enum.each(@ts_plugins, fn {plugin, plugin_opts} -> if {:field, 3} in plugin.__info__(:functions) do Module.eval_quoted( __MODULE__, plugin.field( unquote(name), unquote(Macro.escape(type)), unquote(opts) ++ plugin_opts ) ) end end) end end @doc false def __field__(mod, name, type, opts) when is_atom(name) do if mod |> Module.get_attribute(:ts_fields) |> Keyword.has_key?(name) do raise ArgumentError, "the field #{inspect(name)} is already set" end has_default? = Keyword.has_key?(opts, :default) enforce_by_default? = Module.get_attribute(mod, :ts_enforce?) enforce? = if is_nil(opts[:enforce]), do: enforce_by_default? && !has_default?, else: !!opts[:enforce] nullable? = !has_default? && !enforce? Module.put_attribute(mod, :ts_fields, {name, opts[:default]}) Module.put_attribute(mod, :ts_types, {name, type_for(type, nullable?)}) if enforce?, do: Module.put_attribute(mod, :ts_enforce_keys, name) end def __field__(_mod, name, _type, _opts) do raise ArgumentError, "a field name must be an atom, got #{inspect(name)}" end # Makes the type nullable if the key is not enforced. defp type_for(type, false), do: type defp type_for(type, _), do: quote(do: unquote(type) | nil) end