Introduction

This is a post about effectively using Erlang records in Elixir code.

Lately I've been working on an Elixir project that uses a few Erlang libraries that represent data using the record data type.

Here's how Erlang's documentation describes records:

A record is a data structure for storing a fixed number of elements. It has named fields and is similar to a struct in C. [...] record expressions are translated to tuple expressions during compilation

Records exist in Elixir too but are mostly there for compatibility with Erlang (for a variety of reasons you're better off using a struct or a map than a record in pure Elixir code). The official Elixir docs about records are brief and left me unsure about how to actually use them, so after some experimenting I've made this brief guide for reference. I hope you find it useful!

Prerequisites

This guide assumes basic familiarity with Elixir's syntax, pattern matching, and tooling. While no prior Erlang experience is required, you should be comfortable working with Elixir's basic data types like tuples and maps.

Overview of Records in Elixir

Broadly, Elixir's Record module lets you do two things:

1. Extract Definitions

Extract definitions of records from existing Erlang header files into an Elixir module as tuples. The functions extract/2 and extract_all/1 are used for this.

2. Generate Helpers

Generate helpers for creating, accessing, and pattern matching record data types from tuples describing records. The macros defrecord/3 and defrecordp/3 are used for this.

It's crucial to note that you need to do both steps (extract and define) to actually use the records defined in Erlang files inside Elixir code. Extracting record definitions from Erlang headers results in tuples describing the records, but does not generate the helpers for creating, accessing, and pattern matching on records.

Simply put, you need to plumb the output of extract/2 or extract_all/1 into defrecord/2 (or defrecordp/3) to use Erlang records in Elixir code.

Examples of Extracting and Defining Records

As an example, the code below extracts the :gtp record type from the library gtp_packet.hrl and puts a tuple defining the record into gtp_def. Then, :gtp and gtp_def are passed to Record.defrecord/3 to generate code for using the gtp record type:

  gtp_def = Record.extract(:gtp, from_lib: "gtplib/include/gtp_packet.hrl")
  Record.defrecord(:gtp, gtp_def) # generates the macros gtp/0, gtp/1, gtp/2

Extracting and defining records can get really tedious if you find yourself working with many different record types. Thankfully, Elixir also provides a way to extract records in bulk from Erlang headers. Unfortunately, there's no way to define record helpers in bulk. However, the Enum module and a simple lambda function comes to the rescue!

  # Define helpers dynamically based on the extracted records
  Enum.each(Record.extract_all(from_lib: "gtplib/include/gtp_packet.hrl"), fn {name, fields} ->
    Record.defrecord(name, fields)
  end)

Each tuple from extract_all/2 of {name, fields}, where name is simply the name of the record and fields is a tuple describing the record's fields is passed into defrecord/3 just as in the manually-done first example we saw earlier. I think it's kind of strange there's no extract_and_define function in Elixir that does this all for you, but whatever, this way gives you a little more control over things (for example, you could modify the names, such as putting a prefix on them, to avoid a collision if you're importing records from several different Erlang libraries into the same Elixir module).

Using Records in Elixir

After you've managed to generate code for working with records using defrecord/3 you can finally use them! If you called defrecord(:foo, foo_defs) (where foo_defs was returned from extract/2) you'd end up with three new macros all named foo. Let's imagine the record type foo contains the fields bar and baz. In fact, you can do all this in iex as long as you wrap in inside a module definition:

iex> defmodule Foo do
...> Record.defrecord(:foo, [bar: :undefined, baz: :undefined])
...> end

Creating Records

Now let's use foo/0 to create a foo record with default values (spoiler: it's boring, but it's a start):

iex> Foo.foo()
{:foo, :undefined, :undefined}

Notice the result is a tuple starting with :foo.

To make things a little more interesting we can set values in the foo we're creating:

iex> Foo.foo(bar: :asdf, baz: 1234)
{:foo, :asdf, 1234}

Inspecting Records

Let's say we have a record like {:foo, :asdf, 1234} and that we're forgetful and can't remember the order of bar and baz inside the record. We can use the foo/1 macro generated by defrecord/2 earlier to see the keys and the values laid out clearly:

iex> Foo.foo({:foo, :asdf, 1234})
[bar: :asdf, baz: 1234]

Accessing a Field within Records

You can access individual fields within a record using square brackets, similarly to a Map. To do this, you pass the tuple representation of a record into the generated function with 1-arity, here foo/1.

iex> my_foo = Foo.foo(bar: :asdf, baz: 1234)
{:foo, :asdf, 1234}
iex> Foo.foo(my_foo)[:bar]
:asdf
iex> Foo.foo(my_foo)[:baz]
1234

The generated 2-arity function foo/2 allows for the same with slightly different syntax:

iex> Foo.foo(my_foo, :bar)
:asdf
iex> Foo.foo(my_foo, :baz)
1234

Now we can clearly see that bar is set to asdf and baz is set to 1234. This technique can come in really handy for working with records in the repl if you want to inspect them.

Pattern Matching on Records

However, we can go further and even pattern match on fields within records with similar syntax to pattern matching on Maps. Taking the same foo example record as before, bar and baz can be pattern matched into their own variables:

iex> my_foo = Foo.foo(bar: :asdf, baz: 1234)
{:foo, :asdf, 1234}
iex> Foo.foo(bar: bar, baz: baz) = my_foo
{:foo, :asdf, 1234}
iex> bar
:asdf
iex> baz
1234

Pattern matching is very useful if an Erlang library hands you a record from which you want to concisely extract multiple values. Pattern matching can also be done in function heads to select a specific implementation of a function based on the values of its inputs. For more details on pattern matching see the Elixir docs.

Updating Records

Finally, you can update fields in an existing record (leaving other fields untouched) using the 2-arity version of the generated macro, again foo/2 here:

iex> my_foo = Foo.foo(bar: :asdf, baz: 1234)
{:foo, :asdf, 1234}
iex> Foo.foo(my_foo, bar: :qwerty)
{:foo, :qwerty, 1234}
iex> Foo.foo(my_foo, bar: :qwerty, baz: 5678)
{:foo, :qwerty, 5678}

Summary

I've tried to show in detail with plenty of examples how to use the functionality of Elixir's Record module. This includes extracting record definitions from Erlang header files using extract/2 and extract_all/2, generating macros for working with records via defrecord/3 and defrecordp/3, and examples of how to read, write, and update record data types in the previous few subsections.

Hopefully this guide fills in the gaps in the official documentation and makes at least one other person's life a little easier.