TypedStructBuilderValidators
View SourceTypedStructBuilderValidators is a plugin library for TypedStruct which automatically generates type-safe helper methods for creating, updating, and validating structs.
Installation
Add typed_struct_builder_validators to your list of dependencies in mix.exs,
alongside typed_struct itself:
def deps do
[
{:typed_struct, "~> 0.3.0"},
{:typed_struct_builder_validators, "~> 0.1.0"}
]
endTo call validator/1 and validator/2 without parentheses, import this project's formatter rules in your .formatter.exs:
[
import_deps: [:typed_struct_builder_validators],
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]The documentation lives at hexdocs.pm/typed_struct_builder_validators.
Reasoning
A plain typedstruct block gets you a struct and a type, and stops there:
typedstruct module: Order do
field :id, String.t(), enforce: true
field :quantity, non_neg_integer(), enforce: true
field :discount, float(), default: 0.0
endNothing here builds an Order for you, and nothing says that quantity has to
stay non-negative or that discount is a fraction — enforce: true only asserts
that a key is present.
The declared type only helps at the edges of a function:
@spec apply_bulk_discount(Order.t()) :: Order.t()
def apply_bulk_discount(order) do
order
|> Map.put(:quantity, order.quantity - 10)
|> Map.put(:discout, 0.25)
endIn between those edges, the convenient ways to change a struct are the unchecked
ones. Map.put/3 happily adds a misspelled :discout key, %{order | quantity: n}
checks that the key exists but not that the value matches its declared type, and
struct/2 silently drops anything it doesn't recognize. None of that is visible to
the Order.t() on the way in or the way out, so a struct that stopped being a valid
Order halfway through one function usually surfaces somewhere much later, in code
that has no idea where the bad value came from.
The alternatives are hand-writing a constructor and a few validating setters for every struct and keeping them in sync with the fields forever, or reaching for Ecto's embedded schemas and changesets — a lot of machinery when all you wanted was a struct that can't be built wrong.
This library is the step in between. Declare the invariants next to the fields and every construction and update goes through a generated function with a precise spec:
typedstruct module: Order do
plugin TypedStructBuilderValidators
field :id, String.t(), enforce: true
field :quantity, non_neg_integer(), enforce: true
field :discount, float(), default: 0.0
validator &(&1.discount >= 0.0 and &1.discount <= 1.0)
end
# {:error, ["missing required key(s): :quantity"]}
Order.new(%{id: "a-1"})
# {:error, ["unknown key(s): :discout (expected any of: :id, :quantity, :discount)"]}
Order.put(order, %{discout: 0.25})
# {:error, ["&(&1.discount >= 0.0 and &1.discount <= 1.0)"]}
Order.put(order, %{discount: 2.0})
# Flagged by dialyzer at the line that writes it, not at some later boundary
Order.put(order, %{quantity: "10"})The type surface moves from the boundaries of your functions to every point where the struct is actually built or changed, which is where the mistakes are made.
Summary
A TypedStruct plugin that generates validating constructors and updaters.
It enforces validity both at runtime and via compile-time types.
Adding plugin TypedStructBuilderValidators to a typedstruct block defines seven
functions:
new/1— build from a map, returning{:ok, t()}or{:error, reasons}new!/1— build from a map, returningt()or raisingvalidate/1— check an existing struct, returning:okor{:error, reasons}put/2— replace some fields of an existing struct, then revalidateput!/2— replace some fields, returningt()or raisingupdate/2— transform some fields through functions, then revalidateupdate!/2— transform some fields, returningt()or raising
These methods are fully typed in order to provide higher quality dialyzer results.
typedstruct module: MyStruct do
plugin TypedStructBuilderValidators
field :field1, String.t()
field :field2, non_neg_integer(), enforce: true
validator &(&1.field2 > 0)
end
# Results in dialyzer warnings and results in `{:error, reason}` at runtime
MyStruct.new(%{field1: 1})
MyStruct.new(%{field1: 1, field2: 2})
MyStruct.new(%{field1: "hello"})
# Valid
MyStruct.new(%{field1: "hello", field2: 2})new/1, put/2 and update/2 all perform validate/1, as do the raising
variants.
Specs
Each generated function carries a @spec built from the declared field types:
@spec validate(t()) :: :ok | {:error, [String.t()]}
@spec new(%{optional(:field1) => String.t(), field2: float()}) ::
{:ok, t()} | {:error, [String.t()]}
@spec new!(map()) :: t()
@spec put(t(), %{optional(:field1) => String.t(), optional(:field2) => float()}) ::
{:ok, t()} | {:error, [String.t()]}
@spec put!(t(), %{optional(:field1) => String.t(), optional(:field2) => float()}) :: t()
@spec update(t(), %{
optional(:field1) => (String.t() -> String.t()),
optional(:field2) => (float() -> float())
}) :: {:ok, t()} | {:error, [String.t()]}
@spec update!(t(), %{
optional(:field1) => (String.t() -> String.t()),
optional(:field2) => (float() -> float())
}) :: t()For new/1, fields that are enforce: true are required keys and the rest
are optional. For put/2 and update/2, every field is optional.
validate/1 collects all the problems it finds and returns them
together, rather than stopping at the first.
Naming the argument types
If you'd like to use the type signatures used by any of the methods, you can provide a name to declare the type with that the spec then refers to:
typedstruct module: Config, enforce: true do
plugin TypedStructBuilderValidators, fields_type_name: :my_fields_type
field :field1, String.t(), enforce: false
field :field2, float()
end
# Generated
@type my_fields_type :: %{optional(:field1) => String.t(), field2: float()}
@spec new(my_fields_type()) :: {:ok, t()} | {:error, [String.t()]}so that you can use it elsewhere:
@spec init(Config.my_fields_type()) :: t()The three maps are named separately:
:fields_type_name— thenew/1argument:changes_type_name— theput/2andput!/2argument:updates_type_name— theupdate/2andupdate!/2argument
Each takes a bare name, which defines a @type, or a {name, kind} pair
where kind is one of [:type, :typep, :opaque]:
plugin TypedStructBuilderValidators,
fields_type_name: :attrs,
changes_type_name: {:changes, :typep}Renaming the generated functions
Any of these methods can be renamed by passing its default name as an option. The ones you leave out keep their defaults:
typedstruct module: Config, enforce: true do
plugin TypedStructBuilderValidators, new!: :build!, validate: :check
field :field1, String.t()
end
Config.build!(%{field1: "x"})
Config.check(config)
Config.put(config, %{field1: "y"})Generating only some of them
:only narrows the set to the methods you name:
plugin TypedStructBuilderValidators, only: [:put, :put!]Validators
validator/1 takes a predicate on the completed struct:
typedstruct enforce: true do
plugin TypedStructBuilderValidators
field :foo, float()
field :bar, float()
field :baz, non_neg_integer()
validator fn c -> c.baz > 0 end
validator &(&1.foo > &1.bar)
endvalidator/1 is a macro. The predicate is inlined into the final
validate/1 method. Its source doubles as the default failure message.
The two above report "fn c -> c.baz > 0 end" and "&(&1.foo > &1.bar)".
Pass your own message as a second argument when the source is not self-explanatory:
validator &(&1.foo > &1.bar), "foo must name a higher value than bar"A validator passes by returning true or :ok, and fails by returning
false or {:error, reason}. Any other return raises, on the grounds that a
validator returning something unexpected is a bug rather than a rejection.
Returning {:error, reason} replaces the message, which is how to build one
that quotes the offending value.
Validators run against a built struct, so a field left out of attrs is
checked with its declared default rather than skipped. In new/1 they run
only once the struct can be built at all: if a key is missing or unknown,
new/1 reports that and does not run them.
Because the predicate is inlined, it must be a pure function of the struct; it cannot close over variables from the surrounding scope.
Related items
- TypedStruct — the library this is a plugin
for, which generates the struct, its
t()and its@enforce_keys. - Domo — validates struct values against their
t(), including nested structs, with precondition functions. It does much more type checking at runtime than this does, for a correspondingly heavier build. - typed_struct_ecto_changeset —
another TypedStruct plugin, deriving
Ecto.Changesetcasting from the declared fields, if you are already in Ecto territory.
License
MIT, see LICENSE.