Defining types for struct fields

I would like to have a struct in a module without default values for the fields, but I would like each field to be limited to a specific type. Is there a way to define and enforce this in Elixir?

You can define a typespec for your struct but you cannot enforce it.

What you can do in your module is to use a constructor-like function:

def new(props) do
   ... validate the values
   struct(__MODULE__, props)
end

(new is not a special name)

2 Likes

I think that in this case struct!/2 (with bang) will make more sense.

1 Like

I hesitated because if you validate the values beforehand you should not have to use it, but yes it may be safer.

@stevensonmt the difference is that struct!/2 will raise if the given props have extra properties or if keys declared with @enforce_keys are not defined.

1 Like

Thank you! If using struct!/2 it may raise an exception at runtime and crash, but if using the suggested new/1 constructor you can handle the exceptions gracefully, is that right?

No because as I’ve said, new is not special, it is just a function that you have to define (you could call it create or anything else), and in that function (see my snippet) you call either struct/2 or struct!/2. So it is up to you if you allow to raise.

Oh, I see, struct vs struct! in the constructor function. Thanks!

AFAIK using either struct or struct! doesn’t let Dialyzer “see” what’s happening, and that’s what’s doing the type-enforcement when you use something like typed_struct. The only difference between the two is that struct silently ignores keys it doesn’t understand, while struct! raises.

Writing a literal will complain, though: %SomeStruct{key: "value_of_wrong_type"}.

You could accomplish a similar result by carefully type-specing your new function as well.

You can always reduce the opts into your struct however you want. You can validate, rename, transform, do whatever.

A simple example where opts of the wrong type are simply rejected

  def new(opts \\ []) do
    Enum.reduce(opts, %__MODULE__{}, &put_opt/2)
  end

  defp put_opt(opt, acc)

  defp put_opt({:a, a}, acc) when is_integer(a) do
    %{acc | a: a}
  end

  defp put_opt({:b, b}, acc) when is_binary(b) do
    %{acc | b: b}
  end

  defp put_opt({:c, c}, acc) when is_atom(c) do
    %{acc | c: c}
  end

  defp put_opt(_opt, acc) do
    acc
  end
1 Like

That’s a great tip. Can I ask what the first defp put_opt(opt, acc) without further definition is for?

Oh sorry it’s a personal preference when I create a function with multiple clauses to have that. In my opinion it makes it clearer what the args should be, often helps with docs (for public functions), and gives me somewhere to put @doc, @spec, etc. It’s only really required when you have multiple clauses for a function that takes default arguments. https://hexdocs.pm/elixir/Kernel.html#def/2-default-arguments

1 Like

It is called a function head. It is useful, as @brettbeatty said, for optional args and docs, but also simply to give a name to the opt variable, which is good to have in docs. (Although here it is a private function so that does not count :wink: )

1 Like

At the risk of this thread becoming too wide ranging, when is it appropriate to define functions multiple times for different types versus defining a single function that matches a case statement on the arg? Something like:

defp put_opt(opt, acc) do 
  case opt do 
    a when is_integer(a) -> %{ acc | a: a }
    b when is_binary(b) -> %{acc | b: b }
    ...
end

^written on the fly, not sure if that would compile but hoping it gets the idea across.

It depends. While, IMHO, it’s easier to begin with case do, if the body of different branches of case is getting larger it’s nicer to break it into multiple functions. Plus multiple functions if fail - will give you better backtrace info on where to look at. Also, to me, it’s somewhat easier to read when the whole body of the function describes one path rather than keeping condition context in mind.
Or, just whatever is easier to read =)

1 Like