Types, Tuples, Records, Maps & Structs

I see a lot of confusion about a few data types in Elixir. Understanding the history involved here is crucial to understand why the many options exist. Erlang has been around for a long time.

Types

Every program has some notion of types, or "kinds of data". Some languages, like Haskell, have a rich type system that can be leveraged by the compiler to provide strong guarantees of safety. Other languages, like Ruby, have a very fluid type system, enabling dynamic metaprogramming at runtime. One common problem in most languages is how to properly branch based on the type of result we get. Consider the following code:

result = do_something();  
if (is_success(result)) {  
  foo(result);
} else {
  bar(result);
}

Let's explore that snippet in a few different languages/paradigms.

Ruby (Object Oriented)

class Success  
  def new(result)
    @result = result
  end
  def process
    foo(result)
  end
end

class Failure  
  def new(result)
    @result = result
  end
  def process
    bar(@result)
  end
end

# do_something returns either a Success or Failure
result = do_something  
result.process  

Scala (Functional)

do_something match {  
  case Success(result) => foo(result)
  case Failure(result) => bar(result)
}

In an OO environment, we can use effective polymorphism to branch on the result.

In a functional environment, we can branch on the response via pattern matching.

Tuples

Let's take a look at Elixir. Since Elixir is a functional language, a common idiom is:

case do_something do  
  {:success, result} -> foo(result)
  {:failure, result} -> bar(result)
end  

Notice the difference between Scala and Elixir. Instead of using ADTs, Elixir just encodes the type information in the first entry in a tuple. For this particular use case, this works great. There is just as much information available to use at runtime.

Records

A long time ago, tuples were the only effective way to encode type information in Erlang. As people started encoding type information in tuples, access became unwieldy. Consider a case like {:blog_post, author, title, subject}. In order to access the title of the post you need to do elem(post, 2). That's clumsy at best.

Records were added to Erlang to solve that problem. In your code you could do things like blog_post(post, :title) and at compile time it would be translated to elem(post, 2). This made things a little prettier and maintainable. You could change the record structure to be {:blog_post, date, author, title, subject} and the compiler would rewrite blog_post(post, :title) to elem(post, 3) to reflect the new structure.

defrecord :success, [:data]  
defrecord :failure, [:data]

case do_something do  
  success(data: data) -> foo(data)
  failure(data: data) -> bar(data)
end  

This wasn't without problems. Changing the shape of a record was often incompatible with running code, breaking hot code reloading. It sort of provided polymorphism. If all the structs had a data element in slot 2, then calling thing.data would work on multiple record types. The positional requirements were considerable though, so in practice it caused more problems than in solved.

Maps

Maps were added to Erlang in 2013. Maps are unique in that they can be pattern matched on the keys and values they contain. Instead of using tuples, one could do: %{type: :success, result: result}. This is isomorphic to the tuple/records solution of {:success, result}.

case do_something do  
  %{type: :success, result: result} -> foo(result)
  %{type: :error, result: result}   -> bar(result)
end  

Actually, that's not completely true. Maps can be matched on a subset of the keys they contain. %{type: :success, result: result} can be used to match %{type: :success, result: result, debug: debug}. Technically the mapping between {:success, result} and %{type: :success, result: result} in pattern matching is a homomorphism.

This is in practice pretty awesome. If you know that more than one type of response is valid, you can just match on the keys they have in common.

Structs

Structs are an Elixir specialty. With all the power of pattern matching on maps and their encoded "types", Structs were created to standardize this practice. The struct %Success{result: result} is actually just syntactic sugar for %{__struct__: Success, result: result}. Additionally, we are able to define the valid keys in the struct and are given some helper functions to ensure we stick to it.

case do_something do  
  %Success{result: result} -> foo(result)
  %Failure{result: result} -> bar(result)
end  

As a first class citizen in the language, Structs are also useful with protocols. By having a standard notation for data types, we can use protocols to provide polymorphism.

defprotocol Process do  
  def process(result)
end

defimpl Process, for: Success do  
  def process(%{result: result}) do
    foo(result)
  end
end

defimpl Process, for: Failure do  
  def process(%{result: result}) do
    bar(result)
  end
end

# do_something will return a %Success{} or %Failure{}
result = do_something  
Process.process(result)  

Looks a lot like the Ruby example. It's heavier than the other solutions, but very powerful and flexible.

What should I use?

There's a difficult tradeoff between "better" and "idiomatic". Compare using structs vs using tuples, I personally feel that structs are superior in most aspects. However, there's a lot of value in consistency. When {:ok, res} is the return value for a huge portion of the standard library, I can understand that it feels clumsy to be matching structs. However, I believe this is just because structs are still pretty new. I expect to see more and more usage of structs in place of tuples. They offer a lot of advantages and are more extensible.

So what should you use? As always, balance your requirements. Provide abstractions so that if you change your mind it has minimal impact. Get feedback from other developers using your code. When in doubt, keeping it simple is hardly a mistake.

comments powered by Disqus