What is the most idiomatic way to construct structs with complex default values?

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?

2 Likes

why not add another function to handle that?

def with_random_bytes(%Email{} = email) do
  Map.put(email, :bytes, random_bytes())
end

Then in your context or anywhere else you need it can always just call it like:

%Email{}
|> Email.with_random_bytes()
|> ...
4 Likes

Email is a string value, not a struct. How would you do it in this case?

1 Like

There’s no difference, really.

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.

4 Likes

Are Map.new, Range.new, and MapSet.new non-idiomatic? :slight_smile:

100% agree, Session.new(email) or Session.new(opts) with documentation feels right IMO.

4 Likes

I would do it like this to be able to have a default if not passed:

def new(attrs) do
  with %{bytes: nil} = struct <- struct(__MODULE__, attrs) do
    %{struct | bytes: random_bytes()}
  end
end

This way if you pass bytes in it will use whatever bytes you set, however if not set, we can generate it. You can “mimic” the default value.


iex(1)> MyModule.new(%{email: "hello@world.com"})
%MyModule{email: "hello@world.com", bytes: "fjhdsjhkhd23ddw3er"}

iex(2)> MyModule.new(%{email: "hello@world.com", bytes: "123abc"})
%MyModule{email: "hello@world.com", bytes: "123abc"}
1 Like

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?

1 Like

It certainly is! I do it all the time :upside_down_face:

1 Like

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!

1 Like

Sure no problem,

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

2 Likes

It can also be done as a case if you are more comfortable with that.

def new(attrs) do
  case struct(__MODULE__, attrs) do
    %{bytes: nil} = struct ->
      %{struct | bytes: random_bytes()}

    struct ->
      struct
  end
end
2 Likes

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.

2 Likes

The thing is attrs is an Enumerable.t(), so it can be a map or a keyword. So that is why i do it after i create the struct.

But yea very valid if you want to have the two functions as you showed

3 Likes

We can even mix and match both solutions:

def new(attrs) do
  __MODULE__
  |> struct(attrs)
  |> maybe_with_random_bytes()
end

defp maybe_with_random_bytes(%{bytes: nil} = struct) do
  Map.put(struct, :bytes, random_bytes())
end

defp maybe_with_random_bytes(struct), do: struct
3 Likes

I felt this way too until I divorced the word new from “object instantiation”.

I think the official Elixir documentation even says…

Just use data directly:

%__MODULE__{email: email, bytes: random_bytes()}

If that’s not good enough, make a function:

def new(email) do
  %__MODULE__{email: email, bytes: random_bytes()}
end

And finally… metaprogramming (no example… :stuck_out_tongue:).

See the “In other words:” part of this section:
https://elixir-lang.org/getting-started/meta/domain-specific-languages.html#foreword

1 Like

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!
1 Like

I think the post is really about “struct default values that are determined at runtime”…

defmodule Foo
  defstruct [:email, bytes: &random_bytes/0]

  defp random_bytes do
    :erlang.rand_bytes(16)
  end
end

Which admittedly would be kinda nice… :slight_smile:

2 Likes

Your example does not compile :man_shrugging:t2: (I have fixed some compile errors already):

Erlang/OTP 25 [erts-13.0.2] [source] [64-bit] [smp:10:10] [ds:10:10:10] [async-threads:1] [jit] [dtrace]

Interactive Elixir (1.13.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> defmodule Foo do
...(1)>   defstruct [:email, bytes: &random_bytes/0]
...(1)>
...(1)>   defp random_bytes do
...(1)>     :rand.uniform(100)
...(1)>   end
...(1)> end
** (CompileError) iex:2: undefined function random_bytes/0 (there is no such import)
    (elixir 1.13.4) expanding macro: Kernel.defstruct/1
iex(1)>

I was trying to say “this is an example of what OP was asking for”. It doesn’t work, but it would be nice if it did.