Elixir Structs on Steroids
Elixir structs are very powerful but sometimes they require too much boilerplate to use them as strict data mappers. If desired, validation should be chained here and there. If the struct has more than three fields, pattern matching in the function head is to be copy-pasted over the whole module or extracted into a cryptic macro. Even then, although, it quickly becomes hardly maintainable due to unused variables warnings thrown whenever one calls the all-in-one macro and does not use all the variables injected.
I am talking about somewhat like this:
defstructs MyStruct, [:f1, :f2, :f3, :f4, :f5]
defmacrop cont() do
quote do
%MyStruct{
f1: var!(f1),
f2: var!(f2),
f3: var!(f3),
f4: var!(f4),
f5: var!(f5)
} = var!(my_struct)
end
end
...
def update_f3(cont(), value) do
%MyStruct{my_struct | f3: value}
end
The above somehow works, but emits 4 unsolicited warnings for the unused variables f1
, f2
, f4
, and f5
. Also, if the validation is required, it would bring another set of copy-pasta. And copy-pasta begets spaghetti; hardly readable, noisy, unmaintainable code.
Another issue I met in my last project was I have a bunch of function clauses I need to pipe / recursively call one from another. All having the same signature def foo(%MyStruct{}, params)
and all returning the modified %MyStruct{}
back. That sort of task usually arises when one parses text byte-by-byte or transforms the input several times according to some predefined rules. I needed a monadic behaviour there (once any call in the pipe failed, I want the rest transformations to be skipped and the error immediately returned back.)
When I see the clean use-case, say pattern, I produce the reusable package to handle this pattern. Immediately. I’m not really confident about Sandy Metz’ competence in general and I am pretty sure it’s better to avoid code duplication at any reasonable cost. Extracting the code that seems to conform a pattern into a separate reusable package requires exactly the same time and effort as copy-pasting it across the current project.
That said, I created Pyc
package. It allows transparent validation across inserted data with Exvalibur
, chaining functions in a monadic-like way, and have all the keys as local variables inside methods declared with defmethod
without warnings.
It also reasonably implements Access
behaviour for wrapped structs and Collectable
protocol with validation.
Basically, the code using this utility would look like:
defmodule MyStruct do
use Pyc,
definition: [foo: 42, bar: %{}, baz: []],
constraints: [
%{matches: %{bar: ~Q[bar]},
conditions: %{foo: %{min: 30, max: 50}},
guards: %{check_bar: "is_map(bar)"}}]
defmethod :collect_baz, [value] when is_integer(value) do
%__MODULE__{this | baz: [value | baz]}
end
end
Local variables this
alongside with foo
, bar
and baz
are available inside a block. The function MyStruct.collect_baz/3
might be invoked in the following way:
%MyStruct{}
|> MyStruct.collect_baz(42)
|> MyStruct.collect_baz(43)
|> MyStruct.collect_baz(44)
#⇒ %MyStruct{bar: %{}, baz: ',+*', foo: 42}
Also, one might build the struct with comprehesions:
for {k, v} <- [bar: %{some: :other}, baz: [42]],
do: {k, v}, into: %MyStruct{}
#⇒ %MyStruct{bar: %{some: :other}, baz: '*', foo: 42}
If validation fails, all subsequent calls in the pipeline are skipped and the last value caused the validation error is returned.
%MyStruct{}
|> MyStruct.put(:foo, 32)
|> MyStruct.put(:foo, 42)
|> MyStruct.put(:foo, 52)
|> MyStruct.put(:foo, 62)
#⇒ {:error, %MyStruct{bar: %{}, baz: [], foo: 52}}
validate/1
might be invoked at any moment passing an instance of the struct to it.
Sidenote: for anybody curious, Pyc
name refers to 🐍 PYthon Class
since all the functions declared with defmethod/3
macro implicitly receive the instance of self
as the first argument (called this
to admire Javascript naming of implicit garbage injected into the current context.)
Happy structuring!