Nest structs with sub_field

v0.1.4

Use sub_field to declare a struct inside a struct. GuardedStruct generates a real module for each one, so a nested value is a struct with its own builder/1. This guide covers inline nesting, lists of nested structs, reusing a module you already wrote, required fields, unknown keys and nested errors.

Before you begin

You need a guarded module that uses use GuardedStruct and a guardedstruct do block. If you do not have one yet, follow Get started.

Declare a nested struct

A sub_field takes a name, a typespec, options and a do block. The block holds field, sub_field and conditional_field entries, so nesting can go as deep as you need.

elixir
defmodule Account do
  use GuardedStruct

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

    sub_field :owner, struct(), enforce: true do
      field :email, String.t(),
        enforce: true,
        derives: "sanitize(trim, downcase) validate(email_r)"

      field :role, String.t(), default: "owner"
    end

    sub_field :addresses, list(struct()), structs: true do
      field :city, String.t(), enforce: true
      field :zip, String.t(), derives: "validate(string, min_len=3)"
    end
  end
end

GuardedStruct names each generated module after its parent and the camelized field name. The owner field becomes Account.Owner and addresses becomes Account.Addresses.

elixir
Account.builder(%{
  name: "Acme",
  owner: %{email: " [email protected] "},
  addresses: [%{city: "Paris"}, %{city: "Oslo", zip: "0150"}]
})
# {:ok,
#  %Account{
#    name: "Acme",
#    owner: %Account.Owner{email: "[email protected]", role: "owner"},
#    addresses: [
#      %Account.Addresses{city: "Paris", zip: nil},
#      %Account.Addresses{city: "Oslo", zip: "0150"}
#    ]
#  }}

Build a list of nested structs

Add structs: true to a sub_field and the value is a list. Each item goes through the nested module's builder/1. The typespec is yours to choose; list(struct()) matches the shape.

Use a nested module on its own

Every generated module has the full GuardedStruct API. Call it directly when you only have the nested data, and look it up by field name with GuardedStruct.Info.sub_module/2.

elixir
Account.Owner.builder(%{email: "[email protected]"})
# {:ok, %Account.Owner{email: "[email protected]", role: "owner"}}

GuardedStruct.Info.sub_module(Account, :addresses)
# Account.Addresses

Require fields inside a nested struct

Put enforce: true on the sub_field. The key itself becomes required, and so does every field inside it that has no default. A section-level enforce: true does not reach inside a sub_field.

elixir
Account.builder(%{name: "Acme"})
# {:error,
#  [%{field: :owner, action: :required_fields, message: "Please submit required fields."}]}

Read nested errors

An error from a nested struct is wrapped in a map with the parent field name and an errors list. The inner maps have the usual field, action and message keys.

elixir
Account.builder(%{name: "Acme", owner: %{role: "admin"}})
# {:error,
#  [
#    %{
#      field: :owner,
#      errors: [
#        %{field: :email, action: :required_fields, message: "Please submit required fields."}
#      ]
#    }
#  ]}

For a list of nested structs, only the first item that fails is reported, and the error does not say which item it was.

Reuse another guarded module

A plain field can build its value with a module you wrote elsewhere. Use struct: for one map and structs: for a list of maps.

elixir
defmodule Geo do
  use GuardedStruct

  guardedstruct do
    field :lat, float(), enforce: true, derives: "validate(float)"
    field :lng, float(), enforce: true, derives: "validate(float)"
  end
end

defmodule Place do
  use GuardedStruct

  guardedstruct authorized_fields: true do
    field :title, String.t(), enforce: true
    field :geo, struct(), struct: Geo
    field :stops, list(struct()), structs: Geo
  end
end

Place.builder(%{title: "Pier", geo: %{lat: 1.0, lng: 2.0}, stops: [%{lat: 3.0, lng: 4.0}]})
# {:ok,
#  %Place{
#    title: "Pier",
#    geo: %Geo{lat: 1.0, lng: 2.0},
#    stops: [%Geo{lat: 3.0, lng: 4.0}]
#  }}

Two modules cannot point at each other with struct: or structs:. The compiler prints a warning that names the cycle, because building one would build the other forever.

Reject keys you did not declare

By default the builder drops keys that are not fields. Set authorized_fields: true on the section or on a sub_field and every undeclared key becomes an error instead. The check applies to the level where you set it.

elixir
Place.builder(%{title: "Pier", extra: 1})
# {:error,
#  [
#    %{
#      field: :extra,
#      action: :authorized_fields,
#      message: "Unauthorized keys are present in the sent data."
#    }
#  ]}

Next steps