How can I use Decimal.is_decimal as a guard?

I’m trying to figure out how to use Decimal.is_decimal/1 as a guard, so I can write a function that works if the parameters are passed in as strings or decimals. (e.g. if passed in as strings, I convert to decimals first). I was thinking I could use guard clause to accomplish this but I’m getting compiler errors saying is_decimal() is not allowed. I tried creating my own guard as per below but that’s not working either. Is this something allowed by the language or should I take a different approach. Thanks!

defmodule Mondo.Guards do
  defguard check_decimal(number) when %Decimal{coef: foo, exp: _, sign: _} = number
end

defmodule Mondo.Util do 

  def precise_exchange_rate(qty, subtotal)
      when check_decimal(qty) and check_decimal(subtotal) do
      if(Decimal.eq?(qty, 0), do: nil, else: Decimal.div(subtotal, qty))
  end

  def precise_exchange_rate(qty, subtotal) when is_bitstring(qty) and is_bitstring(subtotal) do
 decimal_qty = if qty == "", do: 0, else: Decimal.new(qty)
    decimal_subtotal = if subtotal == "", do: 0, else: Decimal.new(subtotal)

    if(Decimal.eq?(qty, 0), do: nil, else: Decimal.div(decimal_subtotal, decimal_qty))
  end  
end

The canonical way to do what you’re after is to pattern match rather than use a guard. And as you’ve indentified, you can’t pattern match in guards. Using your example:

defmodule Mondo.Util do 

  def precise_exchange_rate(%Decimal{} = qty, %Decimal{} = subtotal) do
      if(Decimal.eq?(qty, 0), do: nil, else: Decimal.div(subtotal, qty))
  end

  def precise_exchange_rate(qty, subtotal) when is_binary(qty) and is_binary(subtotal) do
    decimal_qty = if qty == "", do: 0, else: Decimal.new(qty)
    decimal_subtotal = if subtotal == "", do: 0, else: Decimal.new(subtotal)

    if(Decimal.eq?(qty, 0), do: nil, else: Decimal.div(decimal_subtotal, decimal_qty))
  end  
end

I also suggest you use is_binary/1 rather than is_bitstring/1 since strings in Elixir are binaries (divisible by 8) whereas bit strings can be any number of bits.

2 Likes

Since 1.11 there’s also a guard to do the same: Kernel — Elixir v1.13.3

3 Likes

And just a couple of other thoughts about composing multiple function heads for readability. Perhaps my opinion only but I find this easier to parse in my mind:

  @zero Decimal.new(0)
  def precise_exchange_rate("", subtotal), do: precise_exchange_rate("0", subtotal)
  def precise_exchange_rate(qty, ""), do: precise_exchange_rate(qty, "0")
  def precise_exchange_rate(qty, subtotal) when is_binary(qty) and is_binary(subtotal) do
    precise_exchange_rate(Decimal.new(qty), Decimal.new(subtotal))
  end 
  def precise_exchange_rate(%Decimal{} = qty, %Decimal{} = subtotal) do
    if(Decimal.eq?(qty, @zero), do: nil, else: Decimal.div(subtotal, qty))
  end
1 Like

FWIW, most of the functions in Decimal already do this sort of conversion on-demand:

  • the last head of Decimal.div/2
  • the last head of Decimal.compare/2 (called by Decimal.eq?/2)

That would leave only the "" cases to be handled by your code (riffing on @kip’s answer):

def precise_exchange_rate("", subtotal), do: precise_exchange_rate("0", subtotal)
def precise_exchange_rate(qty, ""), do: precise_exchange_rate(qty, "0")
def precise_exchange_rate(qty, subtotal) do
  unless Decimal.eq?(qty, 0) do
    Decimal.div(subtotal, qty)
  end
end

This version has a slightly wider type than the original, as:

  • mixed arguments are handled individually - precise_exchange_rate(Decimal.new("10.0"), "") works
  • integers are accepted as well, matching the Decimal.decimal type declaration
2 Likes

You all are super freaking genius. Thanks for a fantastic set of answers that really help me get to know the “elixir way”! :sunglasses:

1 Like

You can use is_decimal/1 in your guards.

defmodule Foo do
  require Decimal
  
  def check_decimal(term) when Decimal.is_decimal(term) do
    {:ok, term}
  end

  def check_decimal(term) do
    {:error, term}
  end
end
2 Likes

This is how I would do it.

I think the code is self-explanatory, but if you have any questions, please ask.

I made a few changes:

  • swapped the arguments, to mimic the order in div/2
  • the function accepts integers and decimals as well.
defmodule Mondo.Util do
  import Decimal, only: [is_decimal: 1]

  defguardp is_valid_decimal(term) when is_binary(term) or is_integer(term) or is_decimal(term)

  @spec precise_exchange_rate(subtotal :: decimal, quantity :: decimal) :: Decimal.t() | nil
        when decimal: Decimal.decimal() | Decimal.t()

  def precise_exchange_rate(subtotal, quantity)
      when is_valid_decimal(subtotal) and is_valid_decimal(quantity) do
    quantity_decimal = to_decimal(quantity)

    unless Decimal.eq?(quantity_decimal, 0) do
      Decimal.div(to_decimal(subtotal), quantity_decimal)
    end
  end

  defp to_decimal(""),
    do: Decimal.new(0)

  defp to_decimal(term) when is_binary(term) or is_integer(term),
    do: Decimal.new(term)

  defp to_decimal(term) when is_decimal(term),
    do: term
end
1 Like