I have a struct representing a session request with only two fields, email and bytes. Basically the struct should either be passed around as immutable or created with a default value as a function return value. I have a function random_bytes which I want to be the default value of bytes.
Since you can’t declare it like this: defstruct [:email, bytes: random_bytes()]
Someone suggested making a “new” function:
def new(email) do
%__MODULE__{email: email, bytes: random_bytes()}
end
But to me this feels very non-idiomatic. It feels like shoehorning an object oriented pattern into a functional language.
What is the best way to build a strict with a complex default value in elixir?
def with_random_bytes(struct) do
Map.put(struct, :bytes, random_bytes()
end
…
%MyStruct{email: email}
|> MyStruct.with_random_bytes()
However, I think new is the way to go. Don’t overcomplicate things. Document that new structs should be created using the initializer in order to set dynamic defaults.
Thank you, I like this option. Another question, is it idiomatic to use __MODULE__ when accessing the current module name in order to avoid aliasing the module from within itself?
I see it as a personal preference thing. I usually alias the module I am in at the top and use that across the module.
In your case I did not know what your module was called, so i just used that for the example.
I like the Modules name mostly because in code reviews it reads easier to me
I just noticed something else I’m not sure of. Are you able to explain the %{bytes: nil} = struct part? It looks like I’m pattern marching against something that does not exist. Is there something special about how the with statement works in this case?
Does it have something to do with the ← in the with statement? Is it similar to what ← does in a comprehension?
Sorry I’m not familiar with “with” statements, watching a video on them now!
I pattern match “after” I create the struct because I know the struct has that key always present in atom format. So the with tries to match the struct with a nil for :bytes, if it does match, then i return an updated version with the struct (This is why i do the = struct to be able to grab the struct in order to update/return it in the do block). However, If it does not match, withs without an else automatically return the result; which in our case is the struct
Another option would be to just build the default opts with Keyword.put_new_lazy/3. Then you create the struct once without needing to update it, and you still only run the random_bytes() generator when necessary. I don’t know how this would compare performance-wise with the with statement plus the update.
def new(attrs) when is_list(attrs) do
attrs = Keyword.put_new_lazy(attrs, :bytes, &random_bytes/0)
struct(__MODULE__, attrs)
end
# optionally if you want to accept both list and map arguments
def new(attrs) when is_map(attrs), do: new(Enum.to_list(attrs))
I always use __MODULE__ just in case I want to change the module name down the line, I don’t have to update anything else in that file.
Quick note: 99% of the time when you write struct you really mean struct! because
struct! will raise if required keys from @enforce_keys are omitted
struct! will raise if given keys that aren’t allowed in the struct
Another pattern that can catch bad keys at compile-time: use a struct literal combined with a function to apply defaults. Something like:
defmodule SessionRequest do
defstruct [:email, :random_bytes]
def setup(%__MODULE__{} = session) do
Map.put_new_lazy(session, :bytes, fn -> random_bytes() end)
end
end
# at the callsite:
session_request =
%SessionRequest{
email: some_email
}
|> SessionRequest.setup()
This is a little over-abstracted for the case where SessionRequest only has one field supplied by the user, but would make more sense if there were a dozen:
the compiler will catch invalid keys in the literal
if you’ve declared types for the specific keys, Dialyzer may catch errors like assigning a literal string to a key declared to be integer(). It will NOT catch misconfigured use of struct/struct!