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.
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
endGuardedStruct names each generated module after its parent and the camelized field name. The owner field becomes Account.Owner and addresses becomes Account.Addresses.
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.
Account.Owner.builder(%{email: "[email protected]"})
# {:ok, %Account.Owner{email: "[email protected]", role: "owner"}}
GuardedStruct.Info.sub_module(Account, :addresses)
# Account.AddressesRequire 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.
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.
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.
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.
Place.builder(%{title: "Pier", extra: 1})
# {:error,
# [
# %{
# field: :extra,
# action: :authorized_fields,
# message: "Unauthorized keys are present in the sent data."
# }
# ]}Next steps
- Accept more than one shape with conditional_field: one field name, several possible shapes.
- Fill and check fields from other fields:
from:andon:paths that start atroot. - DSL reference: every option of
sub_field.