Proper way of handling simple booleans in `with` statement

Say I have a bunch of simple boolean conditions I want to check in a with statement, something like

with true <- is_integer(foo),
     true <- rem(foo,2) == 0,
     true <- foo > 50 do
# do something
else
# distinguish with one failed
end

^ when I want to always distinguish which one failed, even if they are super simple conditions, do I always need to wrap then in a function that returns {:ok, "greater than 50"} vs {:error, "not greater than 50"}? or is there a more elegant way of using these super simple conditions in with statement? The conditions may be simple, but I do need to know which one exactly failed do deal with it.

5 Likes

I tend to find Ecto changesets my preferred approach for data validation and they provide for specific errors that you are looking for. And a changeset function is also then readily reusable. Have you considered that approach?

5 Likes

something like this?

foo = 50

with true <- is_integer(foo) || :error_foo,
     true <- rem(foo,2) == 0 || :error_rem,
     true <- foo > 50 || :error_50 do
  :ok
else
  :error_foo -> raise("It is not integer")
  :error_rem -> raise("It is not even")
  :error_50 -> raise("It is not greater than 50")
end
8 Likes

or something like this…

with {:a, true} <- {:a, is_integer(foo)},
     {:b, true} <- {:b, rem(foo,2) == 0},
     {:c, true} <- {:c, foo > 50} do
# do something
else
  # distinguish with one failed
  {:a, false} -> ...
  {:b, false} -> ...
  {:c, false} -> ...
end
19 Likes

Loved this one! as it is a generic solution

2 Likes

it’s not about validation. it’s more about a situation where you need to make a simple boolean check somewhere in the middle of 5 other other things in with statement. so I’m basically trying to avoid always wrapping in a function to be able to capture the error.

2 Likes

I use error_* helper functions to wrap booleans with error

def error_if(true, msg), do: {:error, msg}
def error_if(_, _), do: :ok

def error_unless(false, msg), do: {:error, msg}
def error_unless(_, _), do: :ok

def do_something() do
     with :ok <- error_unless(is_integer(foo), :non_integer),
          :ok <- error_unless(rem(foo,2) == 0, :not_even),
          :ok <- error_unless(foo > 50, :foo_gt_50) do
     # do something
     else
       {:error, error} ->
          {:error, error}
     end
end
8 Likes

this might be it! find it most elegant and readable at the same time!

1 Like

It works for checking the truthiness of an expression but it will not work for something else. I think @kokolegorille solution is equally elegant and more generic.

1 Like

Yet, it is tagged as bad practice by @keathley :slight_smile:

tldr

For the same reason, under no circumstances should you annotate your function calls with a name just so you can differentiate between them.

…

If you find yourself doing this, it means that the error conditions matter. Which means that you don’t want with at all. You want case.

7 Likes

So how would you convert that to use case instead of with?

There is an example in the previous link.

Anyway, it is still working code.

I fail to see how it can be adapted to this case where many expressions are evaluated.

@kartheek example seems to do this with custom error handling, and with has only one error.

1 Like

with is very handy to flatten the nested branches. with conditions can be used to validate data before performing and operation in body. In this case, with and else are really helpful.

One size fits all might not work always. We have adapt according to situations.

1 Like

Just to throw some grenades, Jose on tagged with on the ThinkingElixir Podcast and relatedly keathleys Good Bad Elixir.

See also the beware section in the with docs, and a similar question I directed at Saša Jurić.

Jose’s argument (and basically every other “anti-tagger” I have read/heard) is that with is explicitly made to handle the case [sic] where your failure states are pretty unified. “If the error matters, use case” sticks in my head.

My take away is that the else is actually almost an after thought for some edge cases, and that most uses of with probably shouldn’t need/have one.

Do I agree with this? Not really sure. I dont really like nested case statements:

case is_integer(foo) do
  true ->
    case rem(foo,2) == 0 do
      true ->
          case 
          ...  # blergh :vomit:

You could wrap each step in its own ensure_integer, ensure_rem2 function, etc that return :ok | {:error, :x} but that feels like a lot of work for some simple checks.

You could write

def validate_foo(foo) when is_integer(foo) and rem(foo,2) == 0 and foo > 50 do
:ok # or act_foo(foo), whatever
end

def valiate_foo(foo) when is_integer(foo) and rem(foo, 2) == 0 do
  {:error, :under_50}
end


def valiate_foo(foo) when is_integer(foo) do
  {:error, :not_rem_2}
end

but that’s not great either IMO because the logic becomes pretty complicated to keep track of.

I think some of the problem is we reduce these problems down to talk about them when really that abstracts any ability to reason on why we should or shouldn’t use a form. In this case does it matter if your foo is bad? can you recover? is passing {:error, reason} useful?

It’s trite, but “know the rules to break the rules” is a valid idiom.

In some cases, tagging the tuple is just the most ergonomic way to do it. In other cases you may want to wrap it in a data structure (such as using Ecto changesets, which are great outside of Ecto) or use a more structured set of functions or case statements.

I would posit that if you want to return a reasonable error back to the user (i.e. they provided a foo of bad quality), then building a changeset or similarly robust structure around it isn’t a bad idea, because you’re now talking about a business rule which can probably stand to be codified more concretely.

Otherwise perhaps you really only need to return {:error, :invalid_foo} and the UI should just be explicitly stating the qualifiers around foo (must be even, must be over 50, etc).

I do like both eksperimentals x || :error and kartheeks error_* styles.

14 Likes

My solution kinda feels like enterprise fizzbuzz when compared to the other
ones here, but I’ve seen this problem be solved with something along the lines
below. In my case, we log a lot and use metrics a ton, so this approach is biased
towards that.

This approach helps with guiding where the checks go (private function that
respects an ok/error tuple response), which type of message should be logged
and kind of metric you should push up.

The embedded_schema approach @kip mentioned above is also a good one.

def process(foo) do
  with {:ok, _} <- check_integer(foo),
       {:ok, _} <- check_divisible(foo, 2),
       {:ok, _} <- check_greater_than(foo, 50),
       # I'd add your "Do something" call here as well
       {:ok, _} = success <- Something.call(foo) do
    Loger.info("Success!")
    Metrics.increment("something.process.successes")
    success
  else
    {:error, %{message: msg, error_kind: error_kind}} = error ->
      Logger.error("Error processing #{foo}, error message: #{msg}")
      Metrics.increment("something.process.errors", %{tags: [error_kind]})
      error
    error ->
      Logger.error("Error processing #{foo}, unexpected error: #{inspect(error)}")
      Metrics.increment("something.process.errors", %{tags: ["kind:unexpected"]})
      error
  end
end
  
defp check_integer(foo) do
  case is_integer(foo) do
    true -> {:ok, foo}
    _ -> {:error, %{message: "Integer error: #{foo} is not an integer", error_kind: "kind:check_integer"}}
  end
end

defp check_divisible(foo, divisor) do
  case rem(foo, divisor) == 0 do
    true -> {:ok, foo}
    _ -> {:error, %{message: "Divisibility error: #{foo} is not divisible by #{divisor}", error_kind: "kind:check_divisible"}}
  end
end

defp check_greater_than(foo, target) do
  case foo > target do
    true -> {:ok, foo}
    _ -> {:error, %{message: "Comparison error: #{foo} is not greater than #{target}", error_kind: "kind:check_greater_than"}}
  end
end

I thought I should share this even though it is way more verbose than the other solutions here.

6 Likes

In fairness, your example does look very much like validation. What is it you want to do when a branch fails?

1 Like

Great example, especially with the included real world aspects of logging/metrics. Also shows a great use of else where it’s not trying to catch every possible case, just some particular, well defined wrappers. Only looks like enterprise fizzbuzz because we’re talking in such abstracted terms, in real use this would look very appropriate.

3 Likes

I just realised that else is not needed in my previous post.

def do_something(foo) do
     with :ok <- error_unless(is_integer(foo), :non_integer),
          :ok <- error_unless(rem(foo,2) == 0, :not_even),
          :ok <- error_unless(foo > 50, :foo_gt_50) do
     # do something
     end
end

I find error_* functions very handy in ErrorUtils to wrap bool with :ok, {:error, msg }

defmodule ErrorUtils do
  def error_if(true, msg), do: {:error, msg}
  def error_if(_, _), do: :ok

  def error_unless(false, msg), do: {:error, msg}
  def error_unless(_, _), do: :ok

  def error_if_nil(nil, msg), do: {:error, msg}
  def error_if_nil(_, _), do: :ok

  def error_if_empty(enumerable, msg) do
    case Enum.empty?(enumerable) do
      true ->
        {:error, msg}

      false ->
        :ok
    end
  end

end
2 Likes