View Source Control Flow Macros (case, if, unless, cond, with)

Elixir's Kernel documentation refers to these structures as "macros for control-flow". We often refer to them as "blocks" in our changelog, which is a much worse name, to be sure.

You're likely here just to see what Styler does, in which case, please click here to skip the following manifesto on our philosophy regarding the usage of these macros.

Which Control Flow Macro Should I Use?

The number of "blocks" in Elixir means there are many ways to write semantically equivalent code, often leaving developers in the dark as to which structure they should use.

We believe readability is enhanced by using the simplest api possible, whether we're talking about internal module function calls or standard-library macros.

use case, if, or cond when...

We advocate for case and if as the first tools to be considered for any control flow as they are the two simplest blocks. If a branch can be expressed with an if statement, it should be. Otherwise, case is the next best choice. In situations where developers might reach for an if/elseif/else block in other languages, cond do should be used.

(cond do seems to see a paucity of use in the language, but many complex nested expressions or with statements can be improved by replacing them with a cond do).

use unless when...

Never! unless is being deprecated and so should not be used.

use with when...

with great power comes great responsibility

  • Uncle Ben

As the most powerful of the Kernel control-flow expressions, with requires the most cognitive overhead to understand. Its power means that we can use it as a replacement for anything we might express using a case, if, or cond (especially with the liberal application of small private helper functions).

Unfortunately, this has lead to a proliferation of with in codebases where simpler expressions would have sufficed, meaning a lot of Elixir code ends up being harder for readers to understand than it needs to be.

Thus, with is the control-flow structure of last resort. We advocate that with should only be used when more basic expressions do not suffice or become overly verbose. As for verbosity, we subscribe to the Chris Keathley school of thought that judicious nesting of control flow blocks within a function isn't evil and more-often-than-not is superior to spreading implementation over many small single-use functions. We'd even go so far as to suggest that cyclomatic complexity is an inexact measure of code quality, with more than a few false negatives and many false positives.

with is a great way to unnest multiple case statements when every failure branch of those statements results in the same error. This is easily and succinctly expressed with with's else block: else (_ -> :error). As Keathley says though, Avoid Else In With Blocks. Having multiple else clauses "means that the error conditions matter. Which means that you don’t want with at all. You want case."

It's acceptable to use one-line with statements (eg with {:ok, _} <- Repo.update(changeset), do: :ok) to signify that other branches are uninteresting or unmodified by your code, but ultimately that can hide the possible returns of a function from the reader, making it more onerous to debug all possible branches of the code in their mental model of the function. In other words, ideally all function calls in a with statement head have obvious error types for the reader, leaving their omission in the code acceptable as the reader feels no need to investigate further. The example at the start of this paragraph with an Ecto.Repo call is a good example, as most developers in a codebase using Ecto are expected to be familiar with its basic API.

Using case rather than with for branches with unusual failure types can help document code as well as save the reader time in tracking down types. For example, replacing the following with a with statement that only matched against the {:ok, _} tuple would hide from readers that an atypically-shaped 3-tuple is returned when things go wrong.

case some_http_call() do
  {:ok, _response} -> :ok
  {:error, http_error, response} -> {:error, http_error, response}
end

if and unless

Styler removes else: nil clauses:

if a, do: b, else: nil
# styled:
if a, do: b

Negation Inversion

Styler removes negators in the head of if and unless statements by "inverting" the statement. The following operators are considered "negators": !, not, !=, !==

Examples:

# negated `if` statement with no `else` clause are rewritten to `unless`
if not x, do: y
# Styled:
unless x, do: y

# negated `if` statements with an `else` clause have their clauses inverted and negation removed
if !x, do: y, else: z
# Styled:
if x, do: z, else: y

# negated `unless` statements are rewritten to `if`
unless x != y, do: z
# B styled:
if x == y, do: z

# `unless` with `else` is verboten; these are always rewritten to `if` statements
unless x, do: y, else: z
# styled:
if x, do: z, else: y

Because elixir relies on truthy/falsey values for its if statements, boolean casting is unnecessary and so double negation is simply removed.

if !!x, do: y
# styled:
if x, do: y

case

"Erlang heritage" case true/false -> if

Trivial true/false case statements are rewritten to if statements. While this results in a semantically different program, we argue that it results in a better program for maintainability. If the developer wants their case statement to raise when receiving a non-boolean value as a feature of the program, they would better serve their callers by raising something more descriptive.

In other words, Styler leaves the code with better style, trumping obscure exception design :)

# Styler will rewrite this even if the clause order is flipped,
# and if the `false` is replaced with a wildcard (`_`)
case foo do
  true -> :ok
  false -> :error
end

# styled:
if foo do
  :ok
else
  :error
end

Per the argument above, if the if statement is an incorrect rewrite for your program, we recommend this manual fix rewrite:

case foo do
  true -> :ok
  false -> :error
  other -> raise "expected `true` or `false`, got: #{inspect other}"
end

cond

Styler has only one cond statement rewrite: replace 2-clause statements with if statements.

# Given
cond do
  a -> b
  true -> c
end
# Styled
if a do
  b
else
  c
end

with

with statements are extremely expressive. Styler tries to remove any unnecessary complexity from them in the following ways.

Remove Identity Else Clause

Like if statements with nil as their else clause, the identity else clause is the default for with statements and so is removed.

# Given
with :ok <- b(), :ok <- b() do
  foo()
else
  error -> error
end
# Styled:
with :ok <- b(), :ok <- b() do
  foo()
end

Remove The Statement Entirely

While you might think "surely this kind of code never appears in the wild", it absolutely does. Typically it's the result of someone refactoring a pattern away and not looking at the larger picture and realizing that the with statement now serves no purpose.

Maybe someday the compiler will warn about these use cases. Until then, Styler to the rescue.

# Given:
with a <- b(),
     c <- d(),
     e <- f(),
     do: g,
     else: (_ -> h)
# Styled:
a = b()
c = d()
e = f()
g

# Given
with value <- arg do
  value
end
# Styled:
arg

Replace _ <- rhs with rhs

This is another case of "less is more" for the reader.

# Given
with :ok <- x,
     _ <- y(),
     {:ok, _} <- z do
  :ok
end
# Styled:
with :ok <- x,
     y(),
     {:ok, _} <- z do
  :ok
end

Replace non-branching bar <- with bar =

<- is for branching. If the lefthand side is the trivial match (a bare variable), Styler rewrites it to use the = operator instead.

# Given
with :ok <- foo(),
     bar <- baz(),
     :ok <- woo(),
     do: {:ok, bar}
# Styled
 with :ok <- foo(),
      bar = baz(),
      :ok <- woo(),
      do: {:ok, bar}

Move assignments from with statement head

Just because any program could be written entirely within the head of a with statement doesn't mean it should be!

Styler moves assignments that aren't trapped between <- outside of the head. Combined with the non-pattern-matching replacement above, we get the following:

# Given
with foo <- bar,
     x = y,
     :ok <- baz,
     bop <- boop,
     :ok <- blop,
     foo <- bar,
     :success = hope_this_works! do
  :ok
end
# Styled:
foo = bar
x = y

with :ok <- baz,
     bop = boop,
     :ok <- blop do
  foo = bar
  :success = hope_this_works!
  :ok
end

Remove redundant final clause

If the pattern of the final clause of the head is also the with statements do body, styler nixes the final match and makes the right hand side of the clause into the do body.

# Given
with {:ok, a} <- foo(),
     {:ok, b} <- bar(a) do
  {:ok, b}
end
# Styled:
with {:ok, a} <- foo() do
  bar(a)
end

Replace with case

A with statement with a single clause in the head and an else body is really just a case statement putting on airs.

# Given:
with :ok <- foo do
  :success
else
  :fail -> :failure
  error -> error
end
# Styled:
case foo do
  :ok -> :success
  :fail -> :failure
  error -> error
end

Replace with if

Given Styler rewrites trivial case to if, it shouldn't be a surprise that that same rule means that with can be rewritten to if in some cases.

# Given:
with true <- foo(), bar <- baz() do
  {:ok, bar}
else
  _ -> :error
end
# Styled:
if foo() do
  bar = baz()
  {:ok, bar}
else
  :error
end