The beauty of Elixir

A dive into Elixir's elegance

By Ivan Stoev
Elixir

Elixir has been adored by engineers for quite the time now and that is reflected in Stack Overflow’s 2025 survey. Phoenix, the web framework for Elixir, also ranked high in the survey.

So why am I telling you this? Well, because I wanted to thoughts on the language as I’ve worked with it for over 2 years know and honestly it’s amazing.

I recently started yet another personal project and opted to write it in Go because I simply wanted to get better at Go and I do like the language but I’ve ended up missing a lot of the functionality Elixir has to offer but here I’ll explore just a few:

  • Pattern Matching
  • Piping
  • The Mighty with clause

Elixir’s pattern matching, function heads, and piping make complex data transformations feel like a walk through the Jedi Temple - elegant, powerful, and flowing naturally. Let’s explore a real-world example of processing user data through multiple validation and transformation steps.

The flow we’ll implement in both languages

Let’s assume we need to process user registration data through several steps:

  1. Validate input format
  2. Check if user already exists
  3. Hash the password
  4. Create user profile
  5. Send welcome email
  6. Return success response

Elixir Implementation with pipe operator

defmodule UserRegistration do
  # Pattern matching with different function heads
  # Here we try to pattern match on the email, name and password fields
  # If all three do not exist we will hit the process_registration function and return an error
  def process_registration(%{email: email, password: password, name: name}) 
  # is_binary essentially checks if the values are valid strings
  # by using the when guard clause which is nice because it's very simple to validate the incoming values somewhat straight away
      when is_binary(email) and is_binary(password) and is_binary(name) do
    # Beautiful piping - each function feeds into the next
    # The result of the previous function is passed as the 1st argument for the next function
    # Each function returns {:ok, data} or {:error, reason}, so we need to handle this properly
    %{email: email, password: password, name: name}
    |> validate_input()
    |> check_user_exists()
    |> hash_password()
    |> create_user_profile()
    |> send_welcome_email()
    |> build_success_response()
    |> case do
      {:ok, response} -> response
      # Here the reason could be any error coming from the above function
      # In the example using the with clause we can explicitly list them
      # But here I have a catch all and in a real world scenario would probably just Log the Error
      {:error, reason} -> {:error, reason}
    end
  end

  def process_registration(_invalid_input) do
    {:error, "Invalid input format"}
  end

  # Individual functions with pattern matching
  defp validate_input(_), do: {:error, :invalid_input}
  defp validate_input(%{email: email, password: password, name: name}) 
       when byte_size(password) >= 8 and byte_size(name) > 0 do
    case Regex.match?(~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/, email) do
      true -> {:ok, %{email: email, password: password, name: name}}
      false -> {:error, :invalid_email}
    end
  end

  defp check_user_exists({:error, reason}), do: {:error, reason}
  defp check_user_exists({:ok, %{email: email} = data}) do
    case Database.user_exists?(email) do
      false -> {:ok, data}
      true -> {:error, :user_exists}
    end
  end

  defp hash_password({:error, reason}), do: {:error, reason}
  defp hash_password({:ok, %{password: password} = data}) do
    hashed_password = Bcrypt.hash_pwd_salt(password)
    {:ok, Map.put(data, :password, hashed_password)}
  end

  defp create_user_profile({:error, reason}), do: {:error, reason}
  defp create_user_profile({:ok, %{email: email, password: password, name: name}}) do
    user = %User{
      email: email,
      password: password,
      name: name,
      created_at: DateTime.utc_now()
    }
    
    case Database.insert_user(user) do
      {:ok, saved_user} -> {:ok, saved_user}
      {:error, _} -> {:error, :database_error}
    end
  end

  defp send_welcome_email({:error, reason}), do: {:error, reason}
  defp send_welcome_email({:ok, %User{email: email, name: name} = user}) do
    case EmailService.send_welcome(email, name) do
      {:ok, _} -> {:ok, user}
      {:error, _} -> {:error, :email_failed}
    end
  end

  defp build_success_response({:error, reason}), do: {:error, reason}
  defp build_success_response({:ok, %User{id: id, email: email, name: name}}) do
    {:ok, %{
      id: id,
      email: email,
      name: name,
      message: "User registered successfully"
    }}
  end
end

I mean come on, this reads out beautifully and it’s just a glimpse of how good pattern matching and piping can be! And whilst I don’t really mind the whole Go error checking

if err != nil {
    ...
}

Elixir essentially does the same as we have a check for every function call to see if the result is successful or not but it looks so much nicer.

Note If you are using Cursor/ Copilot then the AI auto complete for error handling in Go is indeed fantastic

Alright, let’s have a look at how the same code would look like in Elixir if we used the with clause:

Elixir Implementation with the with clause

defmodule UserRegistrationWith do
  # With clause for complex conditional logic
  def process_registration_with_conditions(user_data) do
    with {:ok, validated} <- validate_input(user_data),
         {:ok, _exists_check} <- check_user_exists(validated),
         {:ok, hashed} <- hash_password(validated),
         {:ok, profile} <- create_user_profile(hashed),
         {:ok, _email} <- send_welcome_email(profile) do
      build_success_response(profile)
    else
      {:error, :invalid_email} -> {:error, "Invalid email format"}
      {:error, :user_exists} -> {:error, "User already exists"}
      {:error, :weak_password} -> {:error, "Password too weak"}
      {:error, :email_failed} -> {:error, "Failed to send welcome email"}
      error -> {:error, "Unknown error: #{inspect(error)}"}
    end
  end
end

I mean, again, this is so easy on the eye. You could expand on the error handling if there was additional logic other than returning the error and have a separate function to handle the different type of errors but I won’t go into that now.

Alright, let’s have a look at how this might look when implemented in Go but really it applies to most languages since you do want to check for errors explicitly anyway.

Note I’m nowhere near and expert in Go so the code could probably be improved drastically but even then the point that Elixir is more elegant stands.

Go Implementation

package auth

import (
    "errors"
    "fmt"
    "regexp"
    "time"
)

type User struct {
    ID        int       `json:"id"`
    Email     string    `json:"email"`
    Password  string    `json:"password"`
    Name      string    `json:"name"`
    CreatedAt time.Time `json:"created_at"`
}

type RegistrationRequest struct {
    Email    string `json:"email"`
    Password string `json:"password"`
    Name     string `json:"name"`
}

type RegistrationResponse struct {
    ID      int    `json:"id"`
    Email   string `json:"email"`
    Name    string `json:"name"`
    Message string `json:"message"`
}

type RegistrationResult struct {
    User  *User
    Error error
}

func ProcessRegistrationWithResult(req RegistrationRequest) RegistrationResult {
    // We need to handle each step manually with explicit error checking
    validated, err := validateInput(req)
    if err != nil {
        return RegistrationResult{Error: err}
    }

    exists, err := checkUserExists(validated)
    if err != nil {
        return RegistrationResult{Error: err}
    }
    if exists {
        return RegistrationResult{Error: errors.New("user already exists")}
    }

    hashed, err := hashPassword(validated)
    if err != nil {
        return RegistrationResult{Error: err}
    }

    user, err := createUserProfile(hashed)
    if err != nil {
        return RegistrationResult{Error: err}
    }

    err = sendWelcomeEmail(user)
    if err != nil {
        return RegistrationResult{Error: err}
    }

    return RegistrationResult{User: user}
}

func validateInput(req RegistrationRequest) (RegistrationRequest, error) {
    if req.Email == "" || req.Password == "" || req.Name == "" {
        return RegistrationRequest{}, errors.New("all fields are required")
    }

    if len(req.Password) < 8 {
        return RegistrationRequest{}, errors.New("password too short")
    }

    emailRegex := regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
    if !emailRegex.MatchString(req.Email) {
        return RegistrationRequest{}, errors.New("invalid email format")
    }

    return req, nil
}

func checkUserExists(req RegistrationRequest) (bool, error) {
    exists, err := Database.UserExists(req.Email)
    if err != nil {
        return false, err
    }
    return exists, nil
}

func hashPassword(req RegistrationRequest) (RegistrationRequest, error) {
    hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
    if err != nil {
        return RegistrationRequest{}, err
    }
    
    req.Password = string(hashedPassword)
    return req, nil
}

func createUserProfile(req RegistrationRequest) (*User, error) {
    user := &User{
        Email:     req.Email,
        Password:  req.Password,
        Name:      req.Name,
        CreatedAt: time.Now(),
    }

    err := Database.InsertUser(user)
    if err != nil {
        return nil, err
    }

    return user, nil
}

func sendWelcomeEmail(user *User) error {
    return EmailService.SendWelcome(user.Email, user.Name)
}

func buildSuccessResponse(user *User) RegistrationResponse {
    return RegistrationResponse{
        ID:      user.ID,
        Email:   user.Email,
        Name:    user.Name,
        Message: "User registered successfully",
    }
}

And to be clear I don’t think the Go implementation is bad, but the Elixir one is a thing of beauty.

Key Differences

1. Pattern Matching vs Manual Validation

  • Elixir: Function heads automatically handle different input patterns
  • Go: Manual validation with explicit error checking

2. Piping vs Sequential Calls

  • Elixir:
      data |> func1() |> func2() |> func3()
  • Go: result1, err := func1(data); if err != nil { return err }; result2, err := func2(result1)... - verbose error handling

3. With Clauses vs Manual Error Propagation

  • Elixir: with clauses handle complex conditional flows elegantly
  • Go: Manual error checking at each step with repetitive patterns

4. Data Transformation

  • Elixir: Immutable data flows through transformations naturally
  • Go: Mutable structs or creating new instances at each step

The Elixir version is not just shorter - it’s more readable, maintainable, and follows the principle of “let it crash” with proper error handling through pattern matching.