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

> 本投稿は[『fukuoka.ex Elixir／Phoenix Advent Calendar 2020』](https://qiita.com/advent-calendar/2020/fukuokaex)の２日目の記事です。



<!--
예를들어 다음과 같은 함수가 있다고 합시다.
-->
例えば、次のような関数があるとしましょう。

```ex
@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
```

<!--
타입은 설정 되어있어 입력이 잘못되었다는 것을 짠사람은 알수 있지만, 유저 입장에서는
잘못된 값이 들어왔을때의 에러는 로직의 문제인지 입력의 문제인지 알기 어렵습니다.

직관적인 에러를 돌려주고 싶으니 가드를 넣어봅시다.
-->

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


```ex
@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
```

<!--
이제 잘못된 입력을 넣으면
-->

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


```ex
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-))))
```


<!--
입력이 잘못돼었다는건 알겠지만 스팩을 해석하려면 코드를 읽어봐야합니다. 이
예제는 인수 입력이 짧은 편이지만 세션이라던가 큰 엑토 스트럭스가 넘겨진
경우에는 줄위치찾기도 힘들죠. when 이후의 매쳐도 원본의 내용과는 달라서 더욱
혼란 스럽습니다.

이제부터 출력을 알기 쉽게하기위해 매칭에러를 사용하지 않고 직접
raise해보겠습니다. 테스트를 먼저 바꾸고 코드를 수정하겠습니다.
리턴이 아닌 raise인 이유는 원래 실패하던 녀석들이라 이 입력들은 정상 범위에 넣지
않기 때문입니다. 정상 범위에 넣어야 한다면 {error, xx}리턴이어도 상관없습니다.
-->

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

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

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

```ex
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
```

<!--
이제 두번째 인자도 에러가 나오도록 해봅시다. 에러가 여러번 나올경우 리턴에 전
메세지가 출력되길 원하므로 여기서는 for문을 사용해 에러메세지를 만들도록
하겠습니다.
-->

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

```ex
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의 코드에서 빌려왔습니다.
-->

コードは、ますます汚れますが、エラーメッセージは大分の読めるようになりました。
しかし、二番目の引数のエラーメッセージは、まだ冗長ですね。もっと良い推薦のために
[jaro distance（驚くことに標準関数！！）](https://hexdocs.pm/elixir/String.html#jaro_distance/2)
で類似の値のみ出力する
[did_you_mean?](https://github.com/elixir-lang/elixir/blob/master/lib/elixir/lib/exception.ex#L1276-L1291)
関数を作成し使用してみましょう。
ここのコードは、elixirのコードから借りてきました。

```ex
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으로 부터 형정보를 입력한다던가, 가드 함수를 정형화한다던가 하는 개선은 필요합니다만, 이런 식으로 메세지를 바꾸어 읽기 편하게 만드는 방법도 있구나 정도로 읽어 주시면 고맙겠습니다.
-->

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