constructor v1.1.0 Constructor behaviour
Constructor is a DSL for defining and validating structs.
To illustrate, let's take a basic User struct you might have in your app.
defmodule ConstructorExampleUser do
@enforce_keys [:id, :role]
@allowed_keys ["id", "role", "first_name", "last_name"]
@type t :: %__MODULE__{
id: integer,
role: :user | :admin,
first_name: String.t(),
last_name: String.t()
}
defstruct [:id, :role, first_name: "", last_name: ""]
def new(v) when is_map(v) do
struct = v |> convert_to_struct()
with :ok <- is_integer(struct.id),
:ok <- is_valid_role(struct.role),
:ok <- is_string(struct.first_name),
:ok <- is_string(struct.last_name) do
{:ok, struct}
else
{:error, e} -> {:error, {:constructor, e}}
end
end
def map_to_struct(v) do
mapped = Enum.map(v, fn {key, value} ->
if Enum.any?(@allowed_keys, fn x -> x == key end) do
{String.to_atom(key), v}
else
{key, v}
end
end)
struct(__MODULE__, mapped)
end
def is_string(value) do
case value do
x when is_binary(x) -> :ok
_ -> {:error, "must be a string"}
end
end
def is_integer(value) do
case value do
x when Kernel.is_integer(x) -> :ok
_ -> {:error, "must be an integer"}
end
end
def is_valid_role(value) do
case value do
:admin -> :ok
:user -> :ok
_ -> {:error, "invalid role"}
end
end
end
Elixir code such as this is pretty standard in most projects (especially those without Ecto). It's explicit, and good for taking input from a user or deserializing a struct from JSON. But it has some flaws:
- It returns on the first validation failure, so the user will have to fix and submit again in order to find out if there's another error.
- You have duplication of field names in the
@type,defstructand@allowed_keysdeclarations. A real pain to change each time you add or remove a field, with the@typetending to fall out of sync with the rest of the module quickly. - It's a lot of code! Some parts, such as
is_integer/1andis_string/1, can easily apply across projects. A production-ready implementation ofmap_to_struct/1would need to be expanded to handle nested structs and lists, all of which needs to be tested.
Constructor solves this problem by providing a constructor/2 macro that allows you to define a
field, typespecs, enforced keys, validations, and coercions all in a handful of lines. Here's how
you would write the above struct with Constructor.
defmodule ConstructorExampleUser do
use Constructor
constructor do
field :id, :integer, constructor: &is_integer/1, enforce: true
field :role, :user | :admin, constructor: &is_valid_role/1, enforce: true
field :first_name, :string, default: "", constructor: &is_string/1
field :last_name, :string, default: "", constructor: &is_string/1
end
def is_valid_role(value) do
case value do
:admin -> {:ok, value}
:user -> {:ok, value}
_ -> {:error, "invalid role!"}
end
end
end
Most of the underlying functionality for Constructor is provided by the :typed_struct library.
The TypedStruct.field/3 macro has been expanded to collect the :constructor option, which is
than used by the generated new/1 methods. I won't repeat the TypedStruct documentation here,
but it's important to note that constructor/2 should behave the same as TypedStruct.typedstruct/2
in all respects that aren't Constructor specific.
You can see that unlike our previous new/1 method, this one will accept keyword lists as well as
maps and return errors for multiple fields.
iex> ConstructorExampleUser.new(id: "foo", role: :admin, first_name: 37)
{:error, {:constructor, %{id: "must be an integer", first_name: "must be a string"}}}
iex> ConstructorExampleUser.new(id: 12, role: :admin, first_name: "Chris")
{:ok, %ConstructorExampleUser{id: 12, first_name: "Chris", last_name: ""}}
iex> ConstructorExampleUser.new!(id: 12, role: :admin, first_name: "Chris")
%ConstructorExampleUser{id: 12, first_name: "Chris", last_name: ""}
Any function that conforms to constructor_fun/0 can be used in the construct field.
Additionally, a new/1 function can also be used to build out a nested struct. For example:
defmodule ConstructorExampleAdmin do
use Constructor
constructor do
field :id, :integer, constructor: &Validate.is_integer/1
field :user, ConstructorExampleUser.t(), constructor: &ConstructorExampleUser.new/1
end
end
iex> ConstructorExampleAdmin.new!(id: 22, user: %{id: 22, first_name: "Chris"})
%ConstructorExampleAdmin{id: 22, user: %ConstructorExampleUser{id: 22, first_name: "Chris"}}
Enforce Keys
If you're defining a struct with the enforce: true option, you'll also want to set the
check_keys: true option in your constructor call. Note that this will cause new/2 to raise
a KeyError if a key that is undefined on the struct is passed as input.
Custom Validation Functions
Custom validation functions will need to confrom to the constructor/0 typespec. The test suite
has many examples.
Optional Callbacks
before_construct/1 and after_construct/1 can be implemented if you need to work with the
entire input at once. If the input was a list of maps, the callbacks will be called for each list
value.
before_construct/1will be called before anything else, so it allows you to manipulate the raw input before the rest of Constructor works on it.after_construct/1will be called last, after everything else. It's good for doing multi-field validatons after the data has been converted and validated.
Link to this section Summary
Types
The :constructor option for the TypedStruct.field/3 macro
Custom functions to be used in TypedStruct.field/3 should conform to this spec
Functions
Declare a struct and other attributes, in conjunction with the TypedStruct.field/3 macro
Callbacks
This callback can be used to perform a complex, multi-field validation after all of the per-field validations have run
This callback runs before everything else. input should always be a map
See new/2
This function is generated by the constructor/2 macro, and will convert input into the struct
it defines
See new!/2
Same as new/2, but returns the untagged struct or raises a Constructor.Exception
Link to this section Types
constructor()
constructor() ::
constructor_fun()
| {m :: module(), f :: atom(), a :: [any()]}
| [constructor_fun() | {module(), atom(), [any()]}]
constructor() :: constructor_fun() | {m :: module(), f :: atom(), a :: [any()]} | [constructor_fun() | {module(), atom(), [any()]}]
The :constructor option for the TypedStruct.field/3 macro.
The first form is a 1-arity function capture. The {m,f,a} form will be used as arguments to
apply/3. This will allow use of N-arity functions, because the field value will always be passed
as a first argument, with the provided args appended. A list of 1-arity funs and/or MFA tuples is
also valid.
constructor_fun()
Custom functions to be used in TypedStruct.field/3 should conform to this spec.
new_opts()
Link to this section Functions
constructor(opts \\ [], list) (macro)
Declare a struct and other attributes, in conjunction with the TypedStruct.field/3 macro.
Constructor.Validate and Constructor.Convert are automatically imported for the scope of this call
only.
Examples
defmodule Car
constructor do
# :constructor option is evaluated *after* `:default` or other options.
field :make, String.t(), default: "", constructor: &is_string/1
field :model, String.t(), constructor: {Validate, :is_string, []}
# when a :constructor is defined as a MFA tuple, the field value from input is passed as the
# 1st argument, with the arguments defined here appended.
field :vin, String.t(), constructor: [&is_string/1, {CustomValidation, :min_length, [17]}]
end
end
Opts
Note: All opts that TypedStruct.typedstruct/2 accepts can be passed here as well.
:nil_to_empty- Whenevernew/2receives anilargument, it will return an empty struct with defaults set. If instead you would like to receivenilback, set this option tofalse.:check_keys- iftrue, raises KeyError if a key passed is not valid for struct. Raises ArgumentError if an enforced key is missing. Note: Iffalse, any enforced keys are ignored. Defaults tofalse.
struct(m, opts, default, fields \\ [])
Link to this section Callbacks
after_construct(input)
This callback can be used to perform a complex, multi-field validation after all of the per-field validations have run.
before_construct(input)
This callback runs before everything else. input should always be a map.
Useful if you want to do some major changes to the data before further conversion and validation.
Examples
# Skip processing if input is already our expected struct
@impl Constructor
def before_construct(%__MODULE__{} = input) do
{:ok, input}
end
@impl Constructor
def before_construct(input) when is_map(input) do
# Convert the map keys to atoms if they match this modules.
input = Morphix.atomorphiform!(input, __keys__())
case input do
%{name: n, value: v} when is_binary(v) -> {:ok, %__MODULE__{name: n, type: "STRING", value: v}}
%{name: n, value: v} when is_integer(v) -> {:ok, %__MODULE__{name: n, type: "INTEGER", value: v}}
_ -> {:ok, input} # you could also return an error tuple here
end
end
new(input)
See new/2
new(input, opts)
This function is generated by the constructor/2 macro, and will convert input into the struct
it defines.
After it coerces input into the appropriate struct, it will call before_construct/1.
If that is successful, all the :constructor options are evaluated. Each field is evaluated individually,
and all errors will be collected and returned. Otherwise, after_construct/1 is called and the
result returned.
Parameters
input- can be a map, a keyword list or a list of maps. Whichever it is will determine the return type.opts- a keyword list of the following options::nil_to_empty- overrides what was set onconstructor/2:check_keys- overrides what was set onconstructor/2
Returns
If input is a map or keyword list, the return type will be {:ok, module}. If it is a list
of maps, it will try and convert each element of the list to the the module, returning
{:ok, [module]}.
In the event of an error, {:error, {:constructor, map}} is returned. The map keys are
the struct parameters and the values are a list of errors for that field.
iex> ConstructorExampleUser.new(id: "foo", role: :admin, first_name: 37)
{:error, {:constructor, %{id: "must be an integer", first_name: "must be an integer"}}}
new!(input)
See new!/2
new!(input, opts)
Same as new/2, but returns the untagged struct or raises a Constructor.Exception