Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Control Flow

06-control-flow.livemd

Control Flow

if and unless

Its similar to JS

defmodule Temperature do
  def check_temp(temp) do
    if temp >= 30 do
      "It's hot!"
    else
      "It's not too hot."
    end
  end

  def check_freezing(temp) do
    unless temp > 0 do
      "It's freezing!"
    else
      "It's not freezing."
    end
  end
end
Temperature.check_freezing(35)
Temperature.check_temp(35)

In elixir everything returns a value, so we can assign the output from if/unless like this

character = "Yoda"

allegiance =
  if character in ["Yoda", "Luke", "Obi-Wan", "Rey"] do
    :jedi
  else
    if character in ["Vader", "Palpatine", "Maul", "Dooku"] do
      :sith
    else
      :unknown
    end
  end

IO.puts "#{character} is a #{allegiance}."    

In elixir variables in elixir are immutable within their specific scope

foo = 1
if true do
  foo = 2
end

Now we have foo outside if block and there is another one inside

when executed, the outer foo has no effect

foo

case

This is similar to a switch statement in other langs.

In JS it looks like this

const character = "Legolas";
let race;

switch (character) {
  case "Aragorn":
  case "Boromir":
    race = "Human";
    break;
  case "Legolas":
    race = "Elf";
    break;
  case "Gimli":
    race = "Dwarf";
    break;
  case "Frodo":
  case "Sam":
  case "Merry":
  case "Pippin":
    race = "Hobbit";
    break;
  case "Gandalf":
    race = "Wizard";
    break;
  default:
    race = "Unknown";
}

console.log(`${character} is a ${race}.`);
// Outputs: Legolas is an Elf.

In elixir

disaster_level_classifier = fn threat ->
  case threat do
    # Threat with minimal power and low destructiveness is a Wolf-level threat
    %{power: power, destructiveness: destructiveness} when power < 3 and destructiveness < 3 ->
      :wolf

    # Threat with moderate power or moderate destructiveness is a Tiger-level threat
    %{power: power, destructiveness: destructiveness}
    when (power >= 3 and power < 5) or (destructiveness >= 3 and destructiveness < 5) ->
      :tiger

    # Threat with high power and high destructiveness is a Demon-level threat
    %{power: power, destructiveness: destructiveness}
    when power >= 5 and destructiveness >= 5 and destructiveness < 8 ->
      :demon

    # Threat with very high power or destructiveness is a Dragon-level threat
    %{power: power, destructiveness: destructiveness} when power >= 8 or destructiveness >= 8 ->
      :dragon

    # A global catastrophe threat level with 
    # extreme power and destructiveness is classified as God-level
    %{power: power, destructiveness: destructiveness} when power >= 10 and destructiveness >= 10 ->
      :god

    # Fallback for unknown threats
    _ ->
      :unknown_threat
  end
end
IO.puts(disaster_level_classifier.(%{power: 2, destructiveness: 2}))        # Outputs: :wolf
IO.puts(disaster_level_classifier.(%{power: 4, destructiveness: 3}))        # Outputs: :tiger
IO.puts(disaster_level_classifier.(%{power: 6, destructiveness: 6}))        # Outputs: :demon
IO.puts(disaster_level_classifier.(%{power: 9, destructiveness: 8}))        # Outputs: :dragon
IO.puts(disaster_level_classifier.(%{power: 10, destructiveness: 10}))      # Outputs: :god
IO.puts(disaster_level_classifier.(%{power: 1, destructiveness: 10}))       # Outputs: :dragon
IO.puts(disaster_level_classifier.(%{unknown_key: :unknown_value}))         # Outputs: :unknown_threat

cond

cond is kind of similar to if/else-if/else statement. the difference between cond and case is in cond you can evaluate functions

defmodule WeatherAdvisor do
  def suggest_activity(weather, temperature) do
    cond do
      sunny?(weather) and temperature > 20 -> "It's a perfect day for a walk!"
      sunny?(weather) and temperature <= 20 -> "It's sunny but chilly. Bundle up for a walk!"
      rainy?(weather) -> "Take an umbrella!"
      snowy?(weather) and temperature <= 0 -> "It's freezing! Build a snowman!"
      true -> "Stay indoors and relax!"
    end
  end

  defp sunny?(weather), do: weather == :sunny
  defp rainy?(weather), do: weather == :rainy
  defp snowy?(weather), do: weather == :snowy
end
IO.puts WeatherAdvisor.suggest_activity(:sunny, 25)    
IO.puts WeatherAdvisor.suggest_activity(:sunny, 15)    
IO.puts WeatherAdvisor.suggest_activity(:rainy, 10)    
IO.puts WeatherAdvisor.suggest_activity(:snowy, -5)    
IO.puts WeatherAdvisor.suggest_activity(:cloudy, 15)   

Use of cond is bit rare btw

with

with is like a way to write the happy path without worrying too much about others.

It is kind of similar to early returns you see in other languages like javascript

Lets look at this Javascript example

function canAccessContent(user) {
  if (!user) {
    return "User not found";
  }
  
  if (!user.isLoggedIn) {
    return "User not logged in";
  }
  
  if (user.age < 18) {
    return "User is too young";
  }
  
  if (!user.isSubscribed) {
    return "User does not have a subscription";
  }
  
  return "Access granted";
}

We can write the same with elixir as follows

defmodule AccessControl do
  def can_access_content(user) do
    with {:ok, user} <- validate_user(user),
         {:ok, _} <- check_login(user),
         {:ok, _} <- check_age(user),
         {:ok, _} <- check_subscription(user) do
      "Access granted"
    else
      {:error, reason} -> reason
    end
  end

  defp validate_user(nil), do: {:error, "User not found"}
  defp validate_user(user), do: {:ok, user}

  defp check_login(%{is_logged_in: false}), do: {:error, "User not logged in"}
  defp check_login(%{is_logged_in: true}), do: {:ok, :logged_in}

  defp check_age(%{age: age}) when age < 18, do: {:error, "User is too young"}
  defp check_age(%{age: _}), do: {:ok, :age_valid}

  defp check_subscription(%{is_subscribed: false}),
    do: {:error, "User does not have a subscription"}

  defp check_subscription(%{is_subscribed: true}), do: {:ok, :subscribed}
end
IO.puts(AccessControl.can_access_content(nil))
IO.puts(AccessControl.can_access_content(%{is_logged_in: false}))
IO.puts(AccessControl.can_access_content(%{is_logged_in: true, age: 16}))
IO.puts(AccessControl.can_access_content(%{is_logged_in: true, age: 20, is_subscribed: false}))
IO.puts(AccessControl.can_access_content(%{is_logged_in: true, age: 20, is_subscribed: true}))

So as you can see we break it down to smaller functions, instead of having everything at one place.

This allows us to clearly see whats going on by just looking at the business logic defined in can_access_content/1

A real world example

This is a JS function which process some transaction data and store them, written with TypeScript

  async handleTransactionsTask(webhookResponse): Promise {
    this.logger.log(
      'Truelayer service transaction webhook response : %o',
      webhookResponse,
    );
    const { results_uri: transactionsDataUri, task_id: taskId } =
      webhookResponse;
    const taskResponse = await TruelayerTask.query()
      .where('taskId', taskId)
      .patch({
        status: webhookResponse.status,
      })
      .returning('*');

    const userId = taskResponse[0].userId;
    const credentialId = taskResponse[0].credentialId;

    const credentialData = await ObBankCredential.query()
      .where('id', credentialId)
      .select('accessToken')
      .first();
    const accessToken = credentialData.accessToken;

    const getTransactionsData = await this.truelayerClient.getDataFromUri(
      accessToken,
      transactionsDataUri,
    );

    if (getTransactionsData.results) {
      for (const transaction of getTransactionsData.results) {
        if (transaction.transaction_type == 'DEBIT') {
          const trxAmount = transaction.amount * 100;
          const amount = Math.floor(trxAmount);
          const transactionDataPayload: TruelayerTransactionType = {
            userId: userId,
            amount: amount,
            currencyCode: transaction.currency,
            merchantName: transaction.merchant_name,
            type: transaction.transaction_type,
            category: transaction.transaction_category,
            provider: Provider.TRUELAYER,
            providerTrxId: transaction.provider_transaction_id,
            description: transaction.description,
            status: TransactionStatus.Pending,
            trxTimestamp: new Date(transaction.timestamp),
          };

          this.logger.log(
            'Truelayer service transaction data payload : %o',
            transactionDataPayload,
          );

          await this.saveTruelayerTransactions(transactionDataPayload);
        }
      }
    }

    return;
  }

And same logic with elixir looks like follows

defmodule TruelayerService do
  alias MyApp.{Repo, TruelayerTask, ObBankCredential, Transaction, TruelayerClient}
  require Logger

  @provider "TRUELAYER"
  @pending_status "Pending"

  def handle_transactions_task(%{"results_uri" => transactions_data_uri, "task_id" => task_id} = webhook_response) do
    Logger.info("Truelayer service transaction webhook response: #{inspect(webhook_response)}")

    with {:ok, %{user_id: user_id, credential_id: credential_id}} <- update_task_status(task_id, webhook_response["status"]),
         {:ok, access_token} <- fetch_access_token(credential_id),
         {:ok, %{"results" => results}} <- TruelayerClient.get_data_from_uri(access_token, transactions_data_uri) do
      process_transactions(results, user_id)
    else
      {:error, reason} ->
        Logger.error("Failed to handle transactions task: #{inspect(reason)}")
        {:error, reason}
    end
  end

  defp update_task_status(task_id, status) do
    query = from(t in TruelayerTask, where: t.task_id == ^task_id, select: %{user_id: t.user_id, credential_id: t.credential_id})

    case Repo.update_all(query, set: [status: status]) do
      {1, [task_response]} -> {:ok, task_response}
      _ -> {:error, :task_not_found}
    end
  end

  defp fetch_access_token(credential_id) do
    case Repo.get(ObBankCredential, credential_id) do
      %ObBankCredential{access_token: access_token} -> {:ok, access_token}
      nil -> {:error, :credential_not_found}
    end
  end

  defp process_transactions(transactions, user_id) do
    transactions
    |> Enum.filter(&amp;(&amp;1["transaction_type"] == "DEBIT"))
    |> Enum.each(&amp;save_transaction(&amp;1, user_id))
  end

  defp save_transaction(transaction, user_id) do
    transaction_data = build_transaction_data(transaction, user_id)
    Logger.info("Processing Truelayer transaction: #{inspect(transaction_data)}")

    case Repo.insert(transaction_data) do
      {:ok, _record} -> :ok
      {:error, changeset} -> Logger.error("Failed to save transaction: #{inspect(changeset.errors)}")
    end
  end

  defp build_transaction_data(transaction, user_id) do
    %Transaction{
      user_id: user_id,
      amount: transaction["amount"] * 100 |> floor(),
      currency_code: transaction["currency"],
      merchant_name: transaction["merchant_name"],
      type: transaction["transaction_type"],
      category: transaction["transaction_category"],
      provider: @provider,
      provider_trx_id: transaction["provider_transaction_id"],
      description: transaction["description"],
      status: @pending_status,
      trx_timestamp: parse_timestamp(transaction["timestamp"])
    }
  end

  defp parse_timestamp(timestamp) do
    case DateTime.from_iso8601(timestamp) do
      {:ok, datetime, _offset} -> datetime
      _ -> DateTime.utc_now()  # Default to current time if parsing fails
    end
  end
end

Pattern Matching on Function head

In elixir its common to have lot of smaller functions for doing tasks. Since elixir has the capability to do pattern matching on almost everywhere we can do it on function heads as well.

this leads to lesser nested code lines

defmodule ShippingCalculator do
  def calculate_shipping(product_type, destination, weight) do
    {base_rate, handling_fee} =
      case {product_type, destination} do
        {"Electronics", :domestic} -> {15.0, 10.0}
        {"Electronics", :international} -> {20.0, 25.0}
        {"Clothing", :domestic} -> {5.0, 2.0}
        {"Clothing", :international} -> {8.0, 5.0}
        {"Furniture", :domestic} -> {25.0, 15.0}
        {"Furniture", :international} -> {30.0, 50.0}
        {_, :domestic} -> {10.0, 3.0}
        {_, :international} -> {12.0, 10.0}
      end

    calculate_cost(base_rate, weight, handling_fee)
  end

  defp calculate_cost(base_rate, weight, handling_fee) do
    shipping_cost = base_rate + weight * 0.5
    shipping_cost + handling_fee
  end
end
IO.puts(ShippingCalculator.calculate_shipping("Electronics", :domestic, 10))   
IO.puts(ShippingCalculator.calculate_shipping("Electronics", :international, 10)) 
IO.puts(ShippingCalculator.calculate_shipping("Clothing", :domestic, 10))       
IO.puts(ShippingCalculator.calculate_shipping("Furniture", :international, 10)) 
IO.puts(ShippingCalculator.calculate_shipping("Toys", :domestic, 10))         

With function head pattern matching

defmodule NewShippingCalculator do
  def calculate_shipping(product_type, destination, weight) do
    {base_rate, handling_fee} = rates_and_fees(product_type, destination)
    calculate_cost(base_rate, weight, handling_fee)
  end

  defp rates_and_fees("Electronics", :domestic), do: {15.0, 10.0}
  defp rates_and_fees("Electronics", :international), do: {20.0, 25.0}
  defp rates_and_fees("Clothing", :domestic), do: {5.0, 2.0}
  defp rates_and_fees("Clothing", :international), do: {8.0, 5.0}
  defp rates_and_fees("Furniture", :domestic), do: {25.0, 15.0}
  defp rates_and_fees("Furniture", :international), do: {30.0, 50.0}
  defp rates_and_fees(_, :domestic), do: {10.0, 3.0}
  defp rates_and_fees(_, :international), do: {12.0, 10.0}

  defp calculate_cost(base_rate, weight, handling_fee) do
    shipping_cost = base_rate + weight * 0.5
    shipping_cost + handling_fee
  end
end
# Usage
IO.puts(NewShippingCalculator.calculate_shipping("Electronics", :domestic, 10))    
IO.puts(NewShippingCalculator.calculate_shipping("Electronics", :international, 10)) 
IO.puts(NewShippingCalculator.calculate_shipping("Clothing", :domestic, 10))        
IO.puts(NewShippingCalculator.calculate_shipping("Furniture", :international, 10))   
IO.puts(NewShippingCalculator.calculate_shipping("Toys", :domestic, 10))           

Now we have removed nesting from the previous code and made it much more cleaner to handle