Elixir's with statement & pipe operator - Understanding & use cases
Author: Manh Vu
Published: 2024-08-13

Intro

Elixir is strange language, some special things like |> and with statement can make us confuse but it so convenient if we understand that.

In this topic, I will explain a little of bit about Elixir pipe operator and with statement.

Pipe operator

Pipe operator has syntax |> and it has same way with pipe in bash script.

One thing we need to remember in here is first argument of a function (except the first function) in pipe is got from result of function right before |> syntax. Example:

m = %{}

m |> Map.put(:a, 1)

In this example, m variable stand before |> will be the first argument in Map.put function. In normal code, it looks like: Map.put(m, :a, 1)

Other example:

f = fn -> %{} end
f.() |> Map.put(:a, 1) |> Map.get(:a)

In this example, the first function f.() looks like normal function. After executed, result will be the first argument in Map.put function, after Map.put function was executed, we have result %{a: 1}. The last function Map.get will have first argument is %{a: 1} (from function right before |> of that), after Map.get is executed we will get value 1 from key :a.

Some syntax styles for pipe operator:

# in a line
map = %{} |> Map.put(:a, 1) |> Map.put(:b, 2)

# in multi lines, I recommend this style.
map =
 %{}
 |> Map.put(:a, 1)
 |> Map.put(:b, 2)

# or
map =
 %{} |>
 Map.put(:a, 1) |>
 Map.put(:b, 2)

A downside of pipe is if a function return an expected value or complex value like {:ok, result} it can raise and a runtime error then we can’t use pipe operator. But luckily we have with statement. Now we go to with statement!

with statement

with statement is very interested thing for work with complex result return from a function. The result has format like {:ok, "Hello"}, {:error, :not_found} or even with a map or list can work with statement.

The first thing we need to remember to work with with in here is if function or expression or a variable (still is an expression) return a result that not match with pattern (I have an topic about pattern if you need) the with statement will return that result (or go to else block if we have).

The second thing we need to remember is after all function/expression in with block before do work as we expected the code will go to do block then we can continue with our happy case.

The third thing we need to known is in unexpected case, a pattern is unmatch (not go to do block) we can process it in else case. We can use pattern in else block for known exactly what happen or simply ignore it.

with statement have way look like pipe operator but help us can check and extract value by pattern for next function/expression.

Example for case all values as we expected:

a_map = %{hello: :world, nested_map: %{"a" => 1, b: 2}}

nested_value =
  with %{nested_map: m} <- a_map,
    %{"a" => value} <- m do
      IO.puts "value in nested map: #{inspect value}"
      value
  end

Or common case for us like:

f1 = fn(n) ->
  {:ok, n}
end

f2 = fn 
  :default ->
    {:ok, 3_000}
  n when is_integer(n) ->
   {:ok, n}
  str when is_bitstring(str) ->
    str
  _ ->
   {:error, :invalid_config}
end

f3 = fn check ->
  with {:ok, config} <- f1.(check),
    {:ok, value} <- f2.(config) do
      IO.puts "valid config, value: #{inspect value}"
  else
    {:error, reason} ->
       IO.puts "invalid config, reason: #{inspect reason}"
    unknown ->
       IO.puts "unknown result, #{inspect unknown}"
  end
end

f3.(:default)
# print:
# valid config, value: 3000

f3.(:a)
# print:
# invalid config, reason: :invalid_config

f3.("default")
# print:
# unknown result, "default"

We can easy work with a multi functions/expressions. we can do match a pattern and extract value from result of function then pass to next function.