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:

  1. 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.
  2. You have duplication of field names in the @type, defstruct and @allowed_keys declarations. A real pain to change each time you add or remove a field, with the @type tending to fall out of sync with the rest of the module quickly.
  3. It's a lot of code! Some parts, such as is_integer/1 and is_string/1, can easily apply across projects. A production-ready implementation of map_to_struct/1 would 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/1 will be called before anything else, so it allows you to manipulate the raw input before the rest of Constructor works on it.
  • after_construct/1 will 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

This function is generated by the constructor/2 macro, and will convert input into the struct it defines

Same as new/2, but returns the untagged struct or raises a Constructor.Exception

Link to this section Types

Link to this type

constructor()
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.

Link to this type

constructor_fun()
constructor_fun() ::
  (field_item :: any() ->
     field_item ::
       {:ok, any()} | {:error, String.t()} | {:error, {:constructor, any()}})

Custom functions to be used in TypedStruct.field/3 should conform to this spec.

Link to this type

new_opts()
new_opts() :: [nil_to_empty: boolean(), check_keys: boolean()]

Link to this section Functions

Link to this macro

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 - Whenever new/2 receives a nil argument, it will return an empty struct with defaults set. If instead you would like to receive nil back, set this option to false.
  • :check_keys - if true, raises KeyError if a key passed is not valid for struct. Raises ArgumentError if an enforced key is missing. Note: If false, any enforced keys are ignored. Defaults to false.
Link to this function

struct(m, opts, default, fields \\ [])

Link to this section Callbacks

Link to this callback

after_construct(input)
after_construct(input :: struct()) ::
  {:ok, struct()} | {:error, {:constructor, map()}}

This callback can be used to perform a complex, multi-field validation after all of the per-field validations have run.

Link to this callback

before_construct(input)
before_construct(input :: map()) ::
  {:ok, struct()} | {:error, {:constructor, map()}}

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
Link to this callback

new(input)
new(input :: map() | keyword() | [map()]) ::
  {:ok, struct() | [struct()] | nil} | {:error, {:constructor, map()}}

See new/2

Link to this callback

new(input, opts)
new(input :: map() | keyword() | [map()], opts :: new_opts()) ::
  {:ok, struct() | [struct()] | nil} | {:error, {:constructor, map()}}

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:

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"}}}
Link to this callback

new!(input)
new!(input :: map() | keyword() | [map()] | nil) ::
  struct() | [struct()] | nil | no_return()

See new!/2

Link to this callback

new!(input, opts)
new!(input :: map() | keyword() | [map() | nil], opts :: new_opts()) ::
  struct() | [struct()] | nil | no_return()

Same as new/2, but returns the untagged struct or raises a Constructor.Exception