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(&(&1["transaction_type"] == "DEBIT"))
|> Enum.each(&save_transaction(&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