How does pattern matching with structs work?

If structs are just maps underneath then how does pattern matching to ensure struct type work?

For example:

defmodule Attendee do
    defstruct name: "", paid: false, over_18: true

    def may_attend_after_party(attendee = %Attendee{}) do
        attendee.paid && attendee.over_18
    end

    def may_attend_after_party_without_struct(attendee = %{name: "", paid: false, over_18: true}) do
        attendee.paid && attendee.over_18
    end
end
iex(1)> Attendee.may_attend_after_party(%Attendee{name: "John"})
false
iex(2)> Attendee.may_attend_after_party(%Attendee{name: "John", paid: true})
true
iex(3)> Attendee.may_attend_after_party_without_struct(%Attendee{name: "John", paid: true})
** (FunctionClauseError) no function clause matching in Attendee.may_attend_after_party_without_struct/1    
    
    The following arguments were given to Attendee.may_attend_after_party_without_struct/1:
    
        # 1
        %Attendee{name: "John", over_18: true, paid: true}
    
    structwtf.exs:8: Attendee.may_attend_after_party_without_struct/1
iex(4)>

%Attendee{} evaluates to %Attendee{name: "", over_18: true, paid: false}, and if structs are just maps underneath, then shouldn’t the behaviour remain similar?

Your example code omits the definition of Attendee.may_attend_after_party_without_struct/1; I tried def may_attend_after_party_without_struct(attendee = %{}) do and it worked, so you’ll need to provide more information about what behavior you’re expecting.

My bad, updated the example

Structs have a magic hidden key __struct__ which gets implicitly matched on when you prepend the struct module in between % and {. The default key/values are not filled in for matches, just the struct key.

2 Likes

In fact they are! :smiling_imp:

iex> Map.put(%{}, :__struct__, Attendee)       
%{__struct__: Attendee}

iex> %Attendee{}
%Attendee{name: "", over_18: true, paid: false}

What happen here? First of all the struct is not valid only because “some map” have __struct__ key even if value is valid. The map needs to have all keys available for such struct. The rest is sugar when inspecting.

The %{} syntax or more precisely % special form is just handled differently. Just to imagine:

defmodule Example do
  def add(a, b), do: a + b
  def add_alt(a, b), do: {:ok, add(a, b)}
end

The only difference between add/2 and add_alt/2 is that first returns just the result and second wraps it in “ok-tuple” which is useful for error handling.

If you check how it’s AST you would see that’s almost the same as in my example:

iex> quote do
  %Attendee{name: "", over_18: true, paid: false}
end
{:%, [],
 [
   {:__aliases__, [alias: false], [:Attendee]},
   {:%{}, [], [name: "", over_18: true, paid: false]}
 ]}

So inside % special form simple map (%{}) is used. The special form simply adds to your map a struct keys with their default values or nil if default value is not specified.

For more information see: %/2 special form

3 Likes

I’m not certain, but i think the way you pattern match in your function “without struct” is not what you intend to do. What you have is:

def may_attend_after_party_without_struct(attendee = %{name: "", paid: false, over_18: true}) do

That means only maps are “matched”, that

  • have an empty name
  • did not pay
  • and are over 18

I think what you actually want is just matching the existence of the keys, as in the following

def may_attend_after_party_without_struct(attendee = %{name: _, paid: _, over_18: _}) do
  attendee.paid && attendee.over_18
end

See how i tell the function head that i want the map to contain those three keys (:name, :paid and :over_18), but i discard the value of those keys - that way i “allow” any value.

Is this what you were hoping for?

1 Like

I understand your pattern matching snippet, and in fact that is what I would expect to work.

However, because structs are matched in this manner:

def may_attend_after_party(attendee = %Attendee{}) do
    attendee.paid && attendee.over_18
end

and because %Attendee{} evaluates to %Attendee{name: "", over_18: true, paid: false} I expected the other behaviour to work.

However @ityonemo has given a very concise answer about why this works and @Eiji has provided an amazing demonstration of the fact.

Thanks a lot! This was my first interaction with the Elixir community, and makes me very happy to be a part of it :smile:

Do you mean those are the default values, if you create an empty Attendee struct? Because as with maps - at least i think - the pattern matching for “apparently empty stuff” is maybe not instantly obvious:

iex(1)> map = %{} = %{a: "foo", b: "bar"}
%{a: "foo", b: "bar"}
iex(2)> map
%{a: "foo", b: "bar"}

As you can see, the “empty map” %{} doe not only match empty maps, but any map. That tends to be very useful for the typical use. But that also means your usage of

def may_attend_after_party(attendee = %Attendee{}) do

does not mean you are trying to match an empty attendee or a default attendee. It means you are matching any attendee struct, no matter the contents.

I hope that was clear - does it make sense to you, the way i described it?

1 Like

“Evaluates to” is the gotcha here, the arguments of a function are always a “match context” which has special properties - this:

def some_fun(whole_arg = %{single_value: v}) do

and this:

def some_fun(%{single_value: v} = whole_arg) do

both result in the same function that matches a map with a single_value key and binds whole_arg and v.

This is not the same = that you get when writing those statements alone:

# requires `v` to be defined beforehand, binds a new map `whole_arg`
# always succeeds
whole_arg = %{single_value: v}

# vs

# requires `whole_arg` to be defined beforehand as a map, binds `v` 
# fails with MatchError if `whole_arg` does not have a :single_value key
%{single_value: v} = whole_arg

I suspect this is why it’s a common convention to write pattern-matches on structs on the left-hand side:

def may_attend_after_party(%Attendee{} = attendee) do

as it’s clearly distinguishable from the “evaluation” =.

1 Like