Hello Adrian,
Those are excellent questions!
Let's start with a clarification. Records in Elixir are not deprecated, the Record module is not going anywhere and it aims to provide the same feature set as records in Erlang. It is completely fine to use them and I will get to when this might be a good idea by the end of this response. To clarify: when we said that Records in Elixir were deprecated, it was Elixir implementation of records which is long gone by now.
Structs offer a mixture between records and maps. Records are compile-time based, maps are runtime based. Structs aim to add record-like compile time checks on top of maps. This is actually faster and conceptually simpler than trying to add runtime features to Records.
So what can we get with structs that we can't get with records? I can't write code with records that say "match on any record that contains this field". For example, imagine this function:
def name(%{first_name: first, last_name: last}) do
"#{first} #{last}"
end
It will work for any struct that contains the two fields above since we are simply relying on the underlying maps. If I have Teacher and Student structs, I can have one function that will nicely suit both as long as they have both fields, a requirement clearly specified in the function definition). With records, because those checks are structural, they cannot be shared. I would need to write two functions, one for each record.
Another feature that comes with structs is that they are the foundation for polymorphism in Elixir. With structs, we get custom types, and custom types can have their own protocol implementations.
Those are the main two benefits (field-based polymorphism and protocols). However, you may be wondering: couldn't we add those runtime features to Records? In fact, we could and we did! That's how records were used to work in Elixir. We could define a record like:
defrecord Student, [:first_name, :last_name]
def name(record) do
"#{record.first_name} #{record.last_name}"
end
The function above would also work on any record that contains the fields first_name and last_name. Its implementation relies on the fact we can call a function on a tuple like {Student, "josé", "valim"}. So when you defined a record, we automatically defined functions for all of its fields. This added runtime behaviour to records but, because this relies on a tuple dispatch, it was actually slower than accessing or matching fields in a map!
We have also used records for doing the protocols dispatch but it had a big limitation. For example, every time you called any protocol, whenever we saw a tuple where the first element was an atom, like {Student, "josé", "valim"}, we would try to invoke the Student implementation for that protocol. The issue with this approach is that tuples where the first element is an atom are very, very, very common, so we ended-up trying to call protocol implementations for a bunch of different tuples only to find out there was no implementation, that it was a false positive, and it made the whole thing slow. With structs, because the struct tag is in __struct__, it is very unlikely to have conflicts.
Our record implementation had other issues. For example, the fact it relied on tuple dispatch, made them look a lot like objects and they were abused like that very frequently. Also, once you did "record.first_name", all of dialyzer features were gone too!
For all those reasons, bringing compile time checks to maps as structs is simpler and faster, and that's why it makes sense to promote structs in Elixir as defaults. However, what are the downsides?
You have already touched the first one which is limited dialyzer support. However, it is just a matter of time before they make maps better citizens inside dialyzer. And we can actually have better dialyzer support with maps than we could with the old polymorphic records.
The second one is, inside tight loops, where you absolutely don't care about the polymorphic or runtime features of maps, records are going to be faster. This will always be true and, if that is the case, just use records. That's
exactly what we do in Inspect.Algebra where we need to work with different kinds of documents. Using records is simpler and faster because we are asserting on some particular kinds of documents internally (in the linked code though, notice we use macros instead of the Record module due to bootstraping reasons in the compiler).
I hope this clarifies the design decisions behind structs and the few cases where one should still use records in Elixir.