DEV Community

Cover image for Learning Elixir: Tuples
João Paulo Abreu
João Paulo Abreu

Posted on

Learning Elixir: Tuples

Tuples are like standardized forms where each field has a fixed position and specific meaning. In a user registration form, the first field is always the type (:user), the second is the ID, the third is the name, and the fourth is the email. You can't add extra fields or change the order - the structure is fixed and each position has its well-defined purpose. Think of {:user, 123, "Alice", "alice@example.com"} as a structured record where each position tells a specific part of the story. Once created, tuples keep their size and elements in their exact positions, making them perfect for structured data where order and position matter. In this article, we'll explore how tuples work, when to use them, and the patterns that make them indispensable in Elixir programming.

Note: The examples in this article use Elixir 1.18.3. While most operations should work across different versions, some functionality might vary.

Table of Contents

Introduction

Tuples are one of the most fundamental and elegant data structures in Elixir. They serve as the building blocks for many common patterns you'll see throughout Elixir code, from simple coordinate pairs to complex return values that carry both data and status information.

What makes tuples special:

  • Fixed size: Once created, tuples cannot grow or shrink
  • Random access: You can access any element by its position
  • Ordered collection: Elements have specific positions with distinct meanings
  • Heterogeneous: Each element can be a different data type
  • Immutable: Like all Elixir data structures, tuples cannot be modified

Think of tuples as structured forms where each position matters:

  • An address form: {"123 Main St", "Springfield", "IL", "62701"} (street, city, state, zip)
  • A user registration: {:user, 123, "Alice", "alice@example.com"} (type, ID, name, email)
  • Function results: {:ok, "Success message"} or {:error, "Something went wrong"} (status, data)

Let's explore how tuples work and why they're so useful in Elixir!

Understanding Tuples

The Basics

A tuple is a collection of elements wrapped in curly braces {}, where each position has a specific meaning:

# Empty tuple
empty = {}

# Single element tuple
single = {42}

# Two elements (pair)
coordinate = {3, 4}

# Three elements (triple)
person = {"Alice", 25, :active}

# Mixed data types
mixed = {:ok, %{id: 1, name: "Bob"}, ["tag1", "tag2"]}
Enter fullscreen mode Exit fullscreen mode

Basic Operations

tuple = {:user, 123, "Alice", "alice@example.com"}

# Access by index (0-based)
first = elem(tuple, 0)    # :user
second = elem(tuple, 1)   # 123
third = elem(tuple, 2)    # "Alice"

# Get tuple size
size = tuple_size(tuple)  # 4
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> tuple = {:user, 123, "Alice", "alice@example.com"}
{:user, 123, "Alice", "alice@example.com"}

iex> elem(tuple, 0)
:user

iex> elem(tuple, 2)
"Alice"

iex> tuple_size(tuple)
4
Enter fullscreen mode Exit fullscreen mode

Immutability and Updates

Since tuples are immutable, "updating" a tuple creates a new one:

original = {:user, 123, "Alice"}

# Update the name (position 2) - creates a new tuple
updated = put_elem(original, 2, "Alice Smith")
# Result: {:user, 123, "Alice Smith"}

# Original tuple is unchanged
IO.inspect(original)  # {:user, 123, "Alice"}
IO.inspect(updated)   # {:user, 123, "Alice Smith"}
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> original = {:user, 123, "Alice"}
{:user, 123, "Alice"}

iex> updated = put_elem(original, 2, "Alice Smith")
{:user, 123, "Alice Smith"}

iex> original
{:user, 123, "Alice"}
Enter fullscreen mode Exit fullscreen mode

Creating and Accessing Tuples

Creation Patterns

# Coordinate pairs
point = {10, 20}
point3d = {10, 20, 30}

# Status with data
success = {:ok, "File saved successfully"}
error = {:error, "File not found"}

# Database-style records
user = {:user, 1, "john", "john@example.com", :active}
product = {:product, 101, "Laptop", 999.99, 10}

# Time and date information
time_point = {:datetime, 2024, 1, 15, 14, 30, 0}

# Complex nested data
response = {:ok, %{users: [1, 2, 3], total: 3}}
Enter fullscreen mode Exit fullscreen mode

Accessing Elements

user_record = {:user, 42, "Alice", "alice@example.com"}

# Using elem/2 function
user_id = elem(user_record, 1)      # 42
username = elem(user_record, 2)     # "Alice"
email = elem(user_record, 3)        # "alice@example.com"

# Check tuple size
size = tuple_size(user_record)      # 4

# Pattern matching (more idiomatic)
{:user, id, name, email} = user_record
# Now you have: id = 42, name = "Alice", email = "alice@example.com"
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> user_record = {:user, 42, "Alice", "alice@example.com"}
{:user, 42, "Alice", "alice@example.com"}

iex> elem(user_record, 1)
42

iex> {type, id, name, email} = user_record
{:user, 42, "Alice", "alice@example.com"}

iex> name
"Alice"

iex> id
42
Enter fullscreen mode Exit fullscreen mode

Pattern Matching with Tuples

Pattern matching is where tuples really shine. They allow you to destructure data elegantly:

Basic Pattern Matching

defmodule TuplePatterns do
  # Match specific tuple structures
  def handle_result({:ok, data}) do
    "Success! Got: #{inspect(data)}"
  end

  def handle_result({:error, reason}) do
    "Failed: #{reason}"
  end

  def handle_result(_) do
    "Unknown result format"
  end

  # Extract coordinates
  def distance_from_origin({x, y}) do
    :math.sqrt(x * x + y * y)
  end

  # Match and extract user info
  def greet_user({:user, _id, name, _email}) do
    "Hello, #{name}!"
  end

  def greet_user(_), do: "Hello, stranger!"
end
Enter fullscreen mode Exit fullscreen mode

Pattern Matching in Function Clauses

defmodule ResponseHandler do
  # Handle different HTTP response patterns
  def process({:ok, %{status: 200, body: body}}) do
    {:success, body}
  end

  def process({:ok, %{status: 404}}) do
    {:error, :not_found}
  end

  def process({:ok, %{status: status}}) when status >= 500 do
    {:error, :server_error}
  end

  def process({:error, reason}) do
    {:error, reason}
  end

  # Coordinate processing
  def quadrant({x, y}) when x > 0 and y > 0, do: :first
  def quadrant({x, y}) when x < 0 and y > 0, do: :second
  def quadrant({x, y}) when x < 0 and y < 0, do: :third
  def quadrant({x, y}) when x > 0 and y < 0, do: :fourth
  def quadrant({0, _}), do: :on_x_axis
  def quadrant({_, 0}), do: :on_y_axis
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> TuplePatterns.handle_result({:ok, "data"})
"Success! Got: \"data\""

iex> TuplePatterns.handle_result({:error, "timeout"})
"Failed: timeout"

iex> TuplePatterns.distance_from_origin({3, 4})
5.0

iex> ResponseHandler.quadrant({5, 3})
:first

iex> ResponseHandler.quadrant({-2, 4})
:second
Enter fullscreen mode Exit fullscreen mode

Ignoring Elements with Underscore

defmodule SelectiveMatching do
  # Extract only what you need
  def get_user_id({:user, id, _, _}), do: id

  def get_username({:user, _, name, _}), do: name

  def get_user_email({:user, _, _, email}), do: email

  # Extract nested data
  def extract_value({:ok, {_, value, _}}), do: value
  def extract_value(_), do: nil

  # Match specific positions
  def is_admin_user({:user, _, _, _, :admin}), do: true
  def is_admin_user(_), do: false
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> user = {:user, 123, "Alice", "alice@example.com"}
{:user, 123, "Alice", "alice@example.com"}

iex> SelectiveMatching.get_user_id(user)
123

iex> SelectiveMatching.get_username(user)
"Alice"

iex> admin_user = {:user, 456, "Bob", "bob@example.com", :admin}
{:user, 456, "Bob", "bob@example.com", :admin}

iex> SelectiveMatching.is_admin_user(admin_user)
true
Enter fullscreen mode Exit fullscreen mode

Common Tuple Patterns

Result Tuples (Ok/Error Pattern)

This is one of the most common patterns in Elixir:

defmodule FileProcessor do
  def read_file(filename) do
    case File.read(filename) do
      {:ok, content} ->
        {:ok, String.upcase(content)}
      {:error, reason} ->
        {:error, "Failed to read #{filename}: #{reason}"}
    end
  end

  def process_multiple_files(filenames) do
    results = Enum.map(filenames, &read_file/1)

    # Separate successful and failed operations
    {successes, errors} =
      Enum.split_with(results, fn
        {:ok, _} -> true
        {:error, _} -> false
      end)

    success_count = length(successes)
    error_count = length(errors)

    {:summary, success_count, error_count}
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> FileProcessor.read_file("nonexistent.txt")
{:error, "Failed to read nonexistent.txt: enoent"}

iex> FileProcessor.process_multiple_files(["file1.txt", "file2.txt"])
{:summary, 0, 2}
Enter fullscreen mode Exit fullscreen mode

Tagged Tuples for Type Safety

defmodule DatabaseRecord do
  # Different record types with tags
  def create_user(id, name, email) do
    {:user, id, name, email, :active}
  end

  def create_product(id, name, price) do
    {:product, id, name, price, :available}
  end

  def create_order(id, user_id, product_ids) do
    {:order, id, user_id, product_ids, :pending}
  end

  # Pattern match on record type
  def get_id({:user, id, _, _, _}), do: {:user_id, id}
  def get_id({:product, id, _, _, _}), do: {:product_id, id}
  def get_id({:order, id, _, _, _}), do: {:order_id, id}
  def get_id(_), do: {:error, :unknown_record_type}
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> user = DatabaseRecord.create_user(1, "Alice", "alice@example.com")
{:user, 1, "Alice", "alice@example.com", :active}

iex> product = DatabaseRecord.create_product(101, "Laptop", 999.99)
{:product, 101, "Laptop", 999.99, :available}

iex> DatabaseRecord.get_id(user)
{:user_id, 1}

iex> DatabaseRecord.get_id(product)
{:product_id, 101}
Enter fullscreen mode Exit fullscreen mode

State Machines with Tuples

defmodule LightSwitch do
  # Represent state as tuples
  def new(), do: {:off, 0}  # {state, usage_count}

  def toggle({:off, count}), do: {:on, count + 1}
  def toggle({:on, count}), do: {:off, count}

  def status({:off, count}), do: "Light is OFF (used #{count} times)"
  def status({:on, count}), do: "Light is ON (used #{count} times)"

  def is_on?({:on, _}), do: true
  def is_on?({:off, _}), do: false
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> light = LightSwitch.new()
{:off, 0}

iex> light = LightSwitch.toggle(light)
{:on, 1}

iex> LightSwitch.status(light)
"Light is ON (used 1 time)"

iex> LightSwitch.is_on?(light)
true
Enter fullscreen mode Exit fullscreen mode

Tuples vs Lists

Understanding when to use tuples vs lists is crucial for writing idiomatic Elixir:

Use Tuples When

# 1. Fixed number of elements with specific meanings
coordinate = {x, y}
rgb_color = {255, 128, 64}
user_info = {:user, id, name, email}

# 2. Function return values (especially ok/error)
def divide(a, b) when b != 0, do: {:ok, a / b}
def divide(_, 0), do: {:error, :division_by_zero}

# 3. Small collections where order matters
version = {1, 2, 3}  # major.minor.patch
date = {2024, 1, 15}  # year, month, day

# 4. When you need direct element access
def get_rgb_red({r, _g, _b}), do: r
def get_rgb_green({_r, g, _b}), do: g
def get_rgb_blue({_r, _g, b}), do: b
Enter fullscreen mode Exit fullscreen mode

Use Lists When

# 1. Variable number of elements
shopping_list = ["apples", "bananas", "bread"]
user_ids = [1, 5, 12, 33, 101]

# 2. Elements of the same type/purpose
temperatures = [20.5, 21.0, 19.8, 22.1]
error_messages = ["Invalid email", "Password too short"]

# 3. When you'll process elements sequentially
numbers = [1, 2, 3, 4, 5]
doubled = Enum.map(numbers, &(&1 * 2))

# 4. When you need to add/remove elements frequently
def add_item(list, item), do: [item | list]
def remove_item(list, item), do: List.delete(list, item)
Enter fullscreen mode Exit fullscreen mode

Decision Guide

# Choose tuples for:
{:response, status_code, headers, body}     # Structured data
{:coordinate, x, y, z}                      # Fixed dimensions
{:ok, result} | {:error, reason}            # Tagged unions

# Choose lists for:
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]          # Collections to iterate
["red", "green", "blue", "yellow"]         # Variable-length data
[user1, user2, user3]                      # Homogeneous collections
Enter fullscreen mode Exit fullscreen mode

Working with Nested Tuples

Tuples can contain other tuples, creating more complex data structures:

Nested Structure Examples

# Geographic coordinates with metadata
location = {{40.7128, -74.0060}, "New York City", :usa}

# Complex response with nested data
api_response = {
  :ok,
  {200, "OK"},
  %{"content-type" => "application/json"},
  "{\"users\": [1, 2, 3]}"
}

# Nested coordinates (rectangle defined by two points)
rectangle = {{0, 0}, {10, 5}}  # top-left and bottom-right

# Hierarchical data
organization = {
  :company,
  "Tech Corp",
  {:address, "123 Main St", "Tech City", "12345"},
  {:contact, "info@techcorp.com", "+1-555-0123"}
}
Enter fullscreen mode Exit fullscreen mode

Pattern Matching Nested Tuples

defmodule NestedTupleProcessor do
  # Extract nested coordinate data
  def get_city_coordinates({{lat, lon}, city, _country}) do
    "#{city} is at latitude #{lat}, longitude #{lon}"
  end

  # Match nested API response
  def handle_api_response({:ok, {status, _message}, _headers, body})
      when status in 200..299 do
    {:success, body}
  end

  def handle_api_response({:ok, {status, message}, _, _}) do
    {:error, "HTTP #{status}: #{message}"}
  end

  def handle_api_response({:error, reason}) do
    {:error, "Request failed: #{reason}"}
  end

  # Calculate rectangle area
  def rectangle_area({{x1, y1}, {x2, y2}}) do
    width = abs(x2 - x1)
    height = abs(y2 - y1)
    width * height
  end

  # Extract contact info
  def get_company_email({:company, _name, _address, {:contact, email, _phone}}) do
    email
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> location = {{40.7128, -74.0060}, "New York City", :usa}
{{40.7128, -74.006}, "New York City", :usa}

iex> NestedTupleProcessor.get_city_coordinates(location)
"New York City is at latitude 40.7128, longitude -74.006"

iex> rectangle = {{0, 0}, {10, 5}}
{{0, 0}, {10, 5}}

iex> NestedTupleProcessor.rectangle_area(rectangle)
50
Enter fullscreen mode Exit fullscreen mode

Working with Complex Nested Patterns

defmodule DataExtractor do
  # Extract from deeply nested structures
  def extract_user_from_response({:ok, {:data, {:users, users}}, _meta}) do
    {:ok, users}
  end

  def extract_user_from_response({:error, reason}) do
    {:error, reason}
  end

  # Transform nested coordinates
  def translate_point({{x, y}, metadata}, {dx, dy}) do
    {{x + dx, y + dy}, metadata}
  end

  # Validate nested structure
  def validate_config({:config, {:database, _url}, {:redis, _host}, {:log_level, level}})
      when level in [:debug, :info, :warn, :error] do
    :valid
  end

  def validate_config(_), do: :invalid
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> response = {:ok, {:data, {:users, ["Alice", "Bob"]}}, %{timestamp: "2024-01-15"}}
{:ok, {:data, {:users, ["Alice", "Bob"]}}, %{timestamp: "2024-01-15"}}

iex> DataExtractor.extract_user_from_response(response)
{:ok, ["Alice", "Bob"]}

iex> point = {{10, 20}, "New York"}
{{10, 20}, "New York"}

iex> DataExtractor.translate_point(point, {5, -3})
{{15, 17}, "New York"}

iex> config = {:config, {:database, "localhost:5432"}, {:redis, "localhost:6379"}, {:log_level, :info}}
{:config, {:database, "localhost:5432"}, {:redis, "localhost:6379"}, {:log_level, :info}}

iex> DataExtractor.validate_config(config)
:valid

iex> bad_config = {:config, {:database, "localhost"}, {:redis, "localhost"}, {:log_level, :invalid}}
{:config, {:database, "localhost"}, {:redis, "localhost"}, {:log_level, :invalid}}

iex> DataExtractor.validate_config(bad_config)
:invalid
Enter fullscreen mode Exit fullscreen mode

Best Practices

Do's and Don'ts

✅ DO: Use tuples for fixed-size structured data

# Good - clear structure with specific meanings
point = {x, y}
color = {red, green, blue}
result = {:ok, data}
Enter fullscreen mode Exit fullscreen mode

✅ DO: Use tagged tuples for type safety

# Good - the tag makes the data type clear
{:user, id, name, email}
{:error, reason}
{:coordinate, x, y}
Enter fullscreen mode Exit fullscreen mode

✅ DO: Pattern match in function definitions

# Good - clear, readable pattern matching
def handle_result({:ok, data}), do: process_data(data)
def handle_result({:error, reason}), do: log_error(reason)
Enter fullscreen mode Exit fullscreen mode

❌ DON'T: Use tuples for collections of similar items

# Bad - use a list instead
colors = {"red", "green", "blue", "yellow", "orange"}

# Good - variable-length collection
colors = ["red", "green", "blue", "yellow", "orange"]
Enter fullscreen mode Exit fullscreen mode

❌ DON'T: Create very large tuples

# Bad - hard to work with, consider a map or struct
huge_tuple = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}

# Good - more readable and flexible
user_data = %{
  id: 1, name: "Alice", email: "alice@example.com",
  age: 30, city: "New York", country: "USA"
}
Enter fullscreen mode Exit fullscreen mode

Size Guidelines

Keep tuples small (2-6 elements ideally). For larger structures, consider maps or structs instead.

Error Handling Patterns

defmodule ErrorHandling do
  # Consistent error tuple patterns
  def safe_divide(a, b) when b != 0 do
    {:ok, a / b}
  end

  def safe_divide(_, 0) do
    {:error, :division_by_zero}
  end

  # Chain operations with tuple results
  def calculate_average(numbers) do
    with {:ok, sum} <- safe_sum(numbers),
         {:ok, count} <- safe_count(numbers),
         {:ok, result} <- safe_divide(sum, count) do
      {:ok, result}
    else
      {:error, reason} -> {:error, reason}
    end
  end

  # Helper functions that maintain tuple pattern
  defp safe_sum([]), do: {:error, :empty_list}
  defp safe_sum(numbers), do: {:ok, Enum.sum(numbers)}

  defp safe_count([]), do: {:error, :empty_list}
  defp safe_count(numbers), do: {:ok, length(numbers)}
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> ErrorHandling.safe_divide(10, 2)
{:ok, 5.0}

iex> ErrorHandling.safe_divide(10, 0)
{:error, :division_by_zero}

iex> ErrorHandling.calculate_average([1, 2, 3, 4, 5])
{:ok, 3.0}

iex> ErrorHandling.calculate_average([])
{:error, :empty_list}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Tuples are a fundamental building block in Elixir that enable elegant, type-safe programming patterns. In this article, we've explored:

  • How tuples work internally
  • Creating and accessing tuple elements
  • Pattern matching techniques for destructuring tuples
  • Common patterns like ok/error tuples and tagged data
  • When to choose tuples over lists and other data structures
  • Working with nested tuple structures
  • Best practices for tuple usage

Key takeaways:

  • Tuples are perfect for fixed-size, structured data
  • Pattern matching makes tuple code readable and reliable
  • The ok/error pattern is essential for error handling
  • Use tagged tuples for type safety
  • Keep tuples small and meaningful
  • Choose tuples for structure, lists for collections

Master tuples, and you'll write more expressive and robust Elixir code. They're the foundation for many advanced patterns you'll encounter as you dive deeper into the Elixir ecosystem.

Further Reading

Next Steps

Now that we understand both lists and tuples, we're ready to explore Maps in Elixir. Maps provide key-value storage and are perfect for representing structured data where you need to access values by name rather than position.

In the next article, we'll explore:

  • Creating and updating maps
  • Pattern matching with maps
  • Map vs struct - when to use each
  • Working with nested maps
  • Essential map functions and operations

Maps complete the trio of essential Elixir data structures, giving you the tools to model any kind of data in your applications!

Top comments (0)