優しいエラーメッセージを書きたい


本投稿は『fukuoka.ex Elixir/Phoenix Advent Calendar 2020』の2日目の記事です。

例えば、次のような関数があるとしましょう。

@type order :: {:price, :asc} | {:price, :desc} | :relevant | :recent | :review_rate
@type options :: {:price_range, lower :: integer, higher :: integer}
@spec search(String.t, order, list(options)) :: list(map)
def search(keyword, order, options) do
  # do something
  :ok
end

タイプは設定されており、入力が間違っていること書いた人は分かるが、使う人の立場では、
無効な値が入って来た時のエラーなのかロジックの問題なのか入力の問題なのか分かりにくいです。
直感的なエラーを返してたいからガードを入れてみましょう。

@orders [{:price, :asc}, {:price, :desc}, :relevant, :recent, :review_rate]
@type order :: {:price, :asc} | {:price, :desc} | :relevant | :recent | :review_rate
@type options :: {:price_range, lower :: integer, higher :: integer}
@spec search(String.t(), order, list(options)) :: list(map)
def search(keyword, order, options \\ []) when is_binary(keyword) and order in @orders do
  # do something
  :ok
end

これで間違った入力を入れると

test "search/3" do
  assert search(1, :price)
end

次のようなエラーが出ます。

||      ** (FunctionClauseError) no function clause matching in ReadableTypeError.search/3
||
||      The following arguments were given to ReadableTypeError.search/3:
||
||          # 1
||          1
||
||          # 2
||          :order
||
||          # 3
||          []
||
||      Attempted function clauses (showing 1 out of 1):
||
||          def search(keyword, order, options) when -is_binary(keyword)- and (-order === {:price, :asc}- or (-order === {:price, :desc}- or (-order === :relevant- or (-order === :recent- or -order === :review_rate-))))

入力が間違っだのは分かるが仕様を解釈するには、コードを読む必要があります。
この例では、引数の入力が短い方が、セッションとか大きなecto structが渡されたりsessionが渡されたりした場合には、バーの位置を見つけるも大変ですね。
when以降のmatchもコードと異なるので、さらに混乱しています。

これから出力を分かりやすくするために、マッチングエラーを使用せずに直接raiseしてみます。テストを最初に変えコードを変更します。

{:error, xxx} リターンではなく、raiseを使う理由は、元の失敗した入力で、入力は正常範囲に入れないからです。
正常範囲に入れたい場合、 {:error, xxx} リターンであっても構いません。

test "search/3" do
  assert_raise TypeError, "First argument keyword should be a string. given: 1" fn ->
    search(1, {:order, :desc})
  end
end

def search(keyword, _order, _options) do
  case is_binary(keyword) do
    true ->
      nil

    false ->
      raise TypeError, "First argument keyword should be a string. given: #{inspect(keyword)}"
  end
end

ここで二番目の引数もエラーが出るようにしてみましょう。
エラーが何度も出てくる場合全メッセージが出力されてほしいので、ここでEnum.mapを使用してエラーメッセージを作成するようします。

assert_raise TypeError, """
First argument keyword should be a string. given: 1
Second argument order should be one of [{:price, :asc}, {:price, :desc}, :relevant, :recent, :review_rate]. given: {:price, :abc}
""", fn ->
  search(1, {:price, :abc})
end

def search(keyword, order, _options) do
  message =
    [
      {keyword, &Kernel.is_binary/1,
       &"First argument keyword should be a string. given: #{inspect(&1)}\n"},
      {order, &(&1 in @orders),
       &"Second argument order should be one of #{inspect(@orders)}. given: #{inspect(&1)}\n"}
    ]
    |> Enum.map(fn {value, match_fn, message_fn} ->
      case match_fn.(value) do
        true -> ""
        false -> message_fn.(value)
      end
    end)
    |> Enum.join("")

  raise TypeError, message
end

コードは、ますます汚れますが、エラーメッセージは大分の読めるようになりました。
しかし、二番目の引数のエラーメッセージは、まだ冗長ですね。もっと良い推薦のために
jaro distance(驚くことに標準関数!!)
で類似の値のみ出力する
did_you_mean?
関数を作成し使用してみましょう。
ここのコードは、elixirのコードから借りてきました。

test "search/3" do
  assert_raise TypeError, """
  First argument keyword should be a string. given: 1
  Second argument order given: {:price, :abc}
  Did you mean one of:

  - {:price, :asc}
  - {:price, :desc}
  """, fn ->
    search(1, {:price, :abc})
  end
end

def search(keyword, order, _options) do
  message =
    [
      {keyword, &Kernel.is_binary/1,
       &"First argument keyword should be a string. given: #{inspect(&1)}\n"},
      {order, &(&1 in @orders),
       &"Second argument order given: #{inspect(&1)}\n#{did_you_mean(&1, @orders)}"}
    ]
    |> Enum.map(fn {value, match_fn, message_fn} ->
      case match_fn.(value) do
        true -> ""
        false -> message_fn.(value)
      end
    end)
    |> Enum.join("")

  raise TypeError, message
end

@function_threshold 0.77
@max_suggestions 5

defp did_you_mean(given, candidates) do
  result =
    for key <- candidates,
        dist = String.jaro_distance(inspect(given), inspect(key)),
        dist >= @function_threshold do
      {dist, key}
    end
    |> Enum.sort(&(elem(&1, 0) >= elem(&2, 0)))
    |> Enum.take(@max_suggestions)
    |> Enum.sort(&(elem(&1, 1) <= elem(&2, 1)))

  case result do
    [] ->
      ""

    suggestions ->
      ["Did you mean one of:\n\n" | Enum.map(suggestions, &"- #{inspect(elem(&1, 1))}\n")]
      |> Enum.join("")
  end
end

思ったより長くなったので、ここで一度切って行こうとします。
まだ@spec @typeから型情報を自動入力するとか、ガード関数を定型化するとかする改善は必要ですが、このようにメッセージを変えて読みやすく作成する方法もあるんだ程度で読んでいただければ幸いです。