Accept more than one shape with conditional_field

v0.1.4

A conditional_field lets one field name accept more than one shape: a preset string or a detailed map, one value or a list of values. You declare each shape as a child, and the builder returns the first child that accepts the input. This guide declares a conditional field, reads its errors, and covers lists, priority and nesting.

Before you begin

You need a guarded module. Nested shapes use sub_field, which Nest structs with sub_field explains.

Declare a conditional field

Every child uses the same name as the conditional field. A child is a field, a sub_field or another conditional_field.

elixir
defmodule Check do
  def string(field, value) when is_binary(value), do: {:ok, field, value}
  def string(field, _), do: {:error, field, "It is not string"}

  def map(field, value) when is_map(value), do: {:ok, field, value}
  def map(field, _), do: {:error, field, "It is not map"}

  def list(field, value) when is_list(value), do: {:ok, field, value}
  def list(field, _), do: {:error, field, "It is not list"}
end

defmodule Account do
  use GuardedStruct

  guardedstruct do
    field :name, String.t(), enforce: true

    conditional_field :plan, any() do
      field :plan, String.t(),
        hint: "preset",
        validator: {Check, :string},
        derives: "validate(enum=String[free::pro::enterprise])"

      sub_field :plan, struct(), hint: "detailed", validator: {Check, :map} do
        field :tier, String.t(), enforce: true
        field :seats, integer(), derives: "validate(integer, min_len=1)"
      end
    end
  end
end

The builder tries the children in the order you wrote them. A child accepts the value when all of these hold:

  • Its validator:, if it has one, returns {:ok, field, value}.
  • Its derives pass.
  • For a sub_field or a struct: child, the nested struct builds.
elixir
Account.builder(%{name: "Acme", plan: "pro"})
# {:ok, %Account{name: "Acme", plan: "pro"}}

Account.builder(%{name: "Acme", plan: %{tier: "custom", seats: 50}})
# {:ok, %Account{name: "Acme", plan: %Account.Plan1{tier: "custom", seats: 50}}}

GuardedStruct numbers the modules of sub_field children in the order they appear, so the first one is Account.Plan1.

Tell the children apart with validators

Each child above has a validator: that checks the shape of the value. A child without a validator: passes the validator step for any value. A sub_field child with no validator then fails to build a string and reports bad_parameters, which hides the real reason. A validator that checks the shape makes the error say what was wrong. The validator is a {Module, :function} tuple that the builder calls with the field name and the value.

Read the errors

When no child accepts the value, you get one error with the action conditionals. Its errors list holds the errors of each child in order. A hint: on a child is copied into its errors as __hint__, so you can tell which child produced which error.

elixir
Account.builder(%{name: "Acme", plan: "gold"})
# {:error,
#  [
#    %{
#      action: :conditionals,
#      field: :plan,
#      errors: [
#        %{
#          field: :plan,
#          action: :enum,
#          message: "Your sent data form plan field is not in the allowed list",
#          __hint__: "preset"
#        },
#        %{field: :plan, action: :validator, message: "It is not map", __hint__: "detailed"}
#      ]
#    }
#  ]}

Accept a list

Add structs: true to the conditional field. Each item of a list goes through the children on its own, and the result is a list. A value that is not a list is still tried against the children as a single value.

elixir
defmodule Contact do
  use GuardedStruct

  guardedstruct do
    conditional_field :contact, any(), structs: true do
      field :contact, String.t(),
        validator: {Check, :string},
        hint: "email",
        derives: "validate(email_r)"

      sub_field :contact, struct(), validator: {Check, :map}, hint: "phone" do
        field :country, String.t(), enforce: true
        field :number, String.t(), enforce: true
      end
    end
  end
end

Contact.builder(%{contact: ["[email protected]", %{country: "NL", number: "123"}]})
# {:ok, %Contact{contact: ["[email protected]", %Contact.Contact1{country: "NL", number: "123"}]}}

When items fail, the errors of every failing item are gathered into one conditionals error with duplicates removed.

Report only the first error

Add priority: true to the conditional field to keep only the first error of the list. The aggregated error then names one child instead of all of them.

elixir
conditional_field :value, any(), priority: true do
  field :value, String.t(), validator: {Check, :string}, hint: "text"

  sub_field :value, struct(), validator: {Check, :map}, hint: "map" do
    field :a, String.t(), enforce: true
  end
end

# builder(%{value: 5}) returns one inner error, the one hinted "text"

Nest conditional fields

A conditional field can sit inside a sub_field that is itself the child of another conditional field, to any depth. The error of the inner one is nested inside the error of the outer one, with the outer child's hint on it.

elixir
defmodule Deep do
  use GuardedStruct

  guardedstruct do
    conditional_field :plan, any() do
      field :plan, String.t(), hint: "preset", validator: {Check, :string}

      sub_field :plan, struct(), hint: "detailed", validator: {Check, :map} do
        field :tier, String.t(), enforce: true

        conditional_field :notes, any() do
          field :notes, String.t(), hint: "single", validator: {Check, :string}
          field :notes, list(), hint: "many", validator: {Check, :list}
        end
      end
    end
  end
end

Deep.builder(%{plan: %{tier: "pro", notes: ["x", "y"]}})
# {:ok, %Deep{plan: %Deep.Plan1{tier: "pro", notes: ["x", "y"]}}}

Next steps