Repository: dashbitco/nimble_options (elixir)

Issue:
Proposal: Support richer error messages
It would be nice to be able to:

1.) Include the key's in the error messages and
2.) Support getting all of the errors at once

I have another layer of error reporting that I pass the result of `NimbleParsec.validate/2` into, and previously I was using a library I wrote https://github.com/albert-io/optimal for validating options. However, I'd rather use this, even though it doesn't have some of the features, since it is backed by a pillar of the community :D

Pull request description:
Return structured errors everywhere
Closes #37.

This is a breaking change. Instead of returning `{:error, string}`, now we return `{:error, %NimbleOptions.ValidationError{}}` everywhere. For now this error has the message and the path to the failing option but we can change it however we want in the future.

cc @leandrocp @kevinkoltz

Patch:
diff --git a/lib/nimble_options.ex b/lib/nimble_options.ex
index 23e0dcb..287562c 100644
--- a/lib/nimble_options.ex
+++ b/lib/nimble_options.ex
@@ -127,8 +127,9 @@ defmodule NimbleOptions do
       ...>   ]
       ...> ]
       ...>
-      ...> NimbleOptions.validate(config, schema)
-      {:error, "(in options [:producer]) required option :module not found, received options: [:concurrency]"}
+      ...> {:error, %NimbleOptions.ValidationError{} = error} = NimbleOptions.validate(config, schema)
+      ...> Exception.message(error)
+      "required option :module not found, received options: [:concurrency] (in options [:producer])"
 
   ## Nested option items
 
@@ -160,11 +161,14 @@ defmodule NimbleOptions do
       ...>   ]
       ...> ]
       ...>
-      ...> NimbleOptions.validate(config, schema)
-      {:error, "(in options [:producer, :rate_limiting]) expected :interval to be a positive integer, got: :oops!"}
+      ...> {:error, %NimbleOptions.ValidationError{} = error} = NimbleOptions.validate(config, schema)
+      ...> Exception.message(error)
+      "expected :interval to be a positive integer, got: :oops! (in options [:producer, :rate_limiting])"
 
   """
 
+  alias NimbleOptions.ValidationError
+
   @basic_types [
     :any,
     :keyword_list,
@@ -193,15 +197,16 @@ defmodule NimbleOptions do
   telling what's wrong with the given options.
   """
   @spec validate(keyword(), schema()) ::
-          {:ok, validated_options :: keyword()} | {:error, reason :: String.t()}
-  def validate(options, schema) do
+          {:ok, validated_options :: keyword()} | {:error, ValidationError.t()}
+  def validate(options, schema) when is_list(options) and is_list(schema) do
     case validate_options_with_schema(schema, options_schema()) do
       {:ok, _validated_schema} ->
         validate_options_with_schema(options, schema)
 
-      {:error, message} ->
+      {:error, %ValidationError{} = error} ->
         raise ArgumentError,
-              "invalid schema given to NimbleOptions.validate/2. Reason: #{message}"
+              "invalid schema given to NimbleOptions.validate/2. " <>
+                "Reason: #{Exception.message(error)}"
     end
   end
 
@@ -211,11 +216,11 @@ defmodule NimbleOptions do
   This function behaves exactly like `validate/2`, but returns the options directly
   if they're valid or raises a `NimbleOptions.ValidationError` exception otherwise.
   """
-  @spec validate!(keyword(), schema()) :: validate_options :: keyword()
+  @spec validate!(keyword(), schema()) :: validated_options :: keyword()
   def validate!(options, schema) do
     case validate(options, schema) do
       {:ok, options} -> options
-      {:error, message} -> raise NimbleOptions.ValidationError, message
+      {:error, %ValidationError{} = error} -> raise error
     end
   end
 
@@ -233,7 +238,7 @@ defmodule NimbleOptions do
 
   """
   @spec docs(schema()) :: String.t()
-  def docs(schema) do
+  def docs(schema) when is_list(schema) do
     NimbleOptions.Docs.generate(schema)
   end
 
@@ -243,16 +248,7 @@ defmodule NimbleOptions do
   end
 
   defp validate_options_with_schema(opts, schema) do
-    case validate_options_with_schema_and_path(opts, schema, _path = []) do
-      {:ok, options} ->
-        {:ok, options}
-
-      {:error, message, []} ->
-        {:error, message}
-
-      {:error, message, path} ->
-        {:error, "(in options #{inspect(path)}) " <> message}
-    end
+    validate_options_with_schema_and_path(opts, schema, _path = [])
   end
 
   defp validate_options_with_schema_and_path(opts, fun, path) when is_function(fun) do
@@ -266,11 +262,8 @@ defmodule NimbleOptions do
          {:ok, options} <- validate_options(schema, opts) do
       {:ok, options}
     else
-      {:error, message} ->
-        {:error, message, path}
-
-      {:error, message, sub_path} ->
-        {:error, message, path ++ sub_path}
+      {:error, %ValidationError{} = error} ->
+        {:error, %ValidationError{error | keys_path: path ++ error.keys_path}}
     end
   end
 
@@ -282,20 +275,20 @@ defmodule NimbleOptions do
         :ok
 
       keys ->
-        {:error, "unknown options #{inspect(keys)}, valid options are: #{inspect(valid_opts)}"}
+        error_tuple("unknown options #{inspect(keys)}, valid options are: #{inspect(valid_opts)}")
     end
   end
 
   defp validate_options(schema, opts) do
     case Enum.reduce_while(schema, opts, &reduce_options/2) do
-      {:error, _, _} = result -> result
+      {:error, %ValidationError{}} = result -> result
       result -> {:ok, result}
     end
   end
 
   defp reduce_options({key, schema_opts}, opts) do
     case validate_option(opts, key, schema_opts) do
-      {:error, _message, _path} = result ->
+      {:error, %ValidationError{}} = result ->
         {:halt, result}
 
       {:ok, value} ->
@@ -312,19 +305,13 @@ defmodule NimbleOptions do
   end
 
   defp validate_option(opts, key, schema) do
-    result =
-      with {:ok, value} <- validate_value(opts, key, schema),
-           :ok <- validate_type(schema[:type], key, value) do
-        if nested_schema = schema[:keys] do
-          validate_options_with_schema_and_path(value, nested_schema, _path = [key])
-        else
-          {:ok, value}
-        end
+    with {:ok, value} <- validate_value(opts, key, schema),
+         :ok <- validate_type(schema[:type], key, value) do
+      if nested_schema = schema[:keys] do
+        validate_options_with_schema_and_path(value, nested_schema, _path = [key])
+      else
+        {:ok, value}
       end
-
-    case result do
-      {:error, message} -> {:error, message, _path = []}
-      other -> other
     end
   end
 
@@ -338,9 +325,10 @@ defmodule NimbleOptions do
         {:ok, opts[key]}
 
       Keyword.get(schema, :required, false) ->
-        {:error,
-         "required option #{inspect(key)} not found, received options: " <>
-           inspect(Keyword.keys(opts))}
+        error_tuple(
+          "required option #{inspect(key)} not found, received options: " <>
+            inspect(Keyword.keys(opts))
+        )
 
       true ->
         :no_value
@@ -348,36 +336,37 @@ defmodule NimbleOptions do
   end
 
   defp validate_type(:non_neg_integer, key, value) when not is_integer(value) or value < 0 do
-    {:error, "expected #{inspect(key)} to be a non negative integer, got: #{inspect(value)}"}
+    error_tuple("expected #{inspect(key)} to be a non negative integer, got: #{inspect(value)}")
   end
 
   defp validate_type(:pos_integer, key, value) when not is_integer(value) or value < 1 do
-    {:error, "expected #{inspect(key)} to be a positive integer, got: #{inspect(value)}"}
+    error_tuple("expected #{inspect(key)} to be a positive integer, got: #{inspect(value)}")
   end
 
   defp validate_type(:atom, key, value) when not is_atom(value) do
-    {:error, "expected #{inspect(key)} to be an atom, got: #{inspect(value)}"}
+    error_tuple("expected #{inspect(key)} to be an atom, got: #{inspect(value)}")
   end
 
   defp validate_type(:timeout, key, value)
        when not (value == :infinity or (is_integer(value) and value >= 0)) do
-    {:error,
-     "expected #{inspect(key)} to be non-negative integer or :infinity, got: #{inspect(value)}"}
+    error_tuple(
+      "expected #{inspect(key)} to be non-negative integer or :infinity, got: #{inspect(value)}"
+    )
   end
 
   defp validate_type(:string, key, value) when not is_binary(value) do
-    {:error, "expected #{inspect(key)} to be an string, got: #{inspect(value)}"}
+    error_tuple("expected #{inspect(key)} to be an string, got: #{inspect(value)}")
   end
 
   defp validate_type(:boolean, key, value) when not is_boolean(value) do
-    {:error, "expected #{inspect(key)} to be an boolean, got: #{inspect(value)}"}
+    error_tuple("expected #{inspect(key)} to be an boolean, got: #{inspect(value)}")
   end
 
   defp validate_type(:keyword_list, key, value) do
     if keyword_list?(value) do
       :ok
     else
-      {:error, "expected #{inspect(key)} to be a keyword list, got: #{inspect(value)}"}
+      error_tuple("expected #{inspect(key)} to be a keyword list, got: #{inspect(value)}")
     end
   end
 
@@ -385,7 +374,9 @@ defmodule NimbleOptions do
     if keyword_list?(value) && value != [] do
       :ok
     else
-      {:error, "expected #{inspect(key)} to be a non-empty keyword list, got: #{inspect(value)}"}
+      error_tuple(
+        "expected #{inspect(key)} to be a non-empty keyword list, got: #{inspect(value)}"
+      )
     end
   end
 
@@ -394,7 +385,7 @@ defmodule NimbleOptions do
   end
 
   defp validate_type(:pid, key, value) do
-    {:error, "expected #{inspect(key)} to be a pid, got: #{inspect(value)}"}
+    error_tuple("expected #{inspect(key)} to be a pid, got: #{inspect(value)}")
   end
 
   defp validate_type(:mfa, _key, {m, f, args}) when is_atom(m) and is_atom(f) and is_list(args) do
@@ -402,7 +393,7 @@ defmodule NimbleOptions do
   end
 
   defp validate_type(:mfa, key, value) when not is_nil(value) do
-    {:error, "expected #{inspect(key)} to be a tuple {Mod, Fun, Args}, got: #{inspect(value)}"}
+    error_tuple("expected #{inspect(key)} to be a tuple {Mod, Fun, Args}, got: #{inspect(value)}")
   end
 
   defp validate_type(:mod_arg, _key, {m, _arg}) when is_atom(m) do
@@ -410,7 +401,7 @@ defmodule NimbleOptions do
   end
 
   defp validate_type(:mod_arg, key, value) do
-    {:error, "expected #{inspect(key)} to be a tuple {Mod, Arg}, got: #{inspect(value)}"}
+    error_tuple("expected #{inspect(key)} to be a tuple {Mod, Arg}, got: #{inspect(value)}")
   end
 
   defp validate_type({:fun, arity}, key, value) do
@@ -422,23 +413,27 @@ defmodule NimbleOptions do
           :ok
 
         {:arity, fun_arity} ->
-          {:error, expected <> "got: function of arity #{inspect(fun_arity)}"}
+          error_tuple(expected <> "got: function of arity #{inspect(fun_arity)}")
       end
     else
-      {:error, expected <> "got: #{inspect(value)}"}
+      error_tuple(expected <> "got: #{inspect(value)}")
     end
   end
 
   defp validate_type({:custom, mod, fun, args}, _key, value) do
-    apply(mod, fun, [value | args])
+    case apply(mod, fun, [value | args]) do
+      {:ok, value} -> {:ok, value}
+      {:error, message} when is_binary(message) -> error_tuple(message)
+    end
   end
 
   defp validate_type({:one_of, choices}, key, value) do
     if value in choices do
       :ok
     else
-      {:error,
-       "expected #{inspect(key)} to be one of #{inspect(choices)}, got: #{inspect(value)}"}
+      error_tuple(
+        "expected #{inspect(key)} to be one of #{inspect(choices)}, got: #{inspect(value)}"
+      )
     end
   end
 
@@ -505,4 +500,8 @@ defmodule NimbleOptions do
       {:error, "expected :doc to be a string or false, got: #{inspect(doc)}"}
     end
   end
+
+  defp error_tuple(message) do
+    {:error, %ValidationError{message: message}}
+  end
 end
diff --git a/lib/nimble_options/validation_error.ex b/lib/nimble_options/validation_error.ex
index 285219f..ebf7936 100644
--- a/lib/nimble_options/validation_error.ex
+++ b/lib/nimble_options/validation_error.ex
@@ -1,11 +1,26 @@
 defmodule NimbleOptions.ValidationError do
   @moduledoc """
-  Raised when options are invalid.
+  An error that is returned (or raised) when options are invalid.
+
+  The contents of this exception are not public and you should not rely on
+  its fields.
+
+  Since this is an exception, you can either raise it directly with `raise/1`
+  or turn it into a message string with `Exception.message/1`.
   """
 
-  defexception [:message]
+  @type t() :: %__MODULE__{}
+
+  defexception [:message, keys_path: []]
+
+  @impl true
+  def message(%__MODULE__{message: message, keys_path: keys_path}) do
+    suffix =
+      case keys_path do
+        [] -> ""
+        keys -> " (in options #{inspect(keys)})"
+      end
 
-  def exception(message) when is_binary(message) do
-    %__MODULE__{message: message}
+    message <> suffix
   end
 end


Test patch:
diff --git a/test/nimble_options_test.exs b/test/nimble_options_test.exs
index 70592a3..84d4aaf 100644
--- a/test/nimble_options_test.exs
+++ b/test/nimble_options_test.exs
@@ -3,6 +3,8 @@ defmodule NimbleOptionsTest do
 
   doctest NimbleOptions
 
+  alias NimbleOptions.ValidationError
+
   test "known options" do
     schema = [name: [], context: []]
     opts = [name: MyProducer, context: :ok]
@@ -16,7 +18,10 @@ defmodule NimbleOptionsTest do
 
     assert NimbleOptions.validate(opts, schema) ==
              {:error,
-              "unknown options [:not_an_option1, :not_an_option2], valid options are: [:an_option, :other_option]"}
+              %ValidationError{
+                message:
+                  "unknown options [:not_an_option1, :not_an_option2], valid options are: [:an_option, :other_option]"
+              }}
   end
 
   describe "validate the schema itself before validating the options" do
@@ -26,11 +31,11 @@ defmodule NimbleOptionsTest do
 
       message = """
       invalid schema given to NimbleOptions.validate/2. \
-      Reason: (in options [:stages]) invalid option type :foo.
+      Reason: invalid option type :foo.
 
       Available types: :any, :keyword_list, :non_empty_keyword_list, :atom, \
       :non_neg_integer, :pos_integer, :mfa, :mod_arg, :string, :boolean, :timeout, \
-      :pid, {:fun, arity}, {:one_of, choices}, {:custom, mod, fun, args}\
+      :pid, {:fun, arity}, {:one_of, choices}, {:custom, mod, fun, args} (in options [:stages])\
       """
 
       assert_raise ArgumentError, message, fn ->
@@ -56,10 +61,11 @@ defmodule NimbleOptionsTest do
 
       message = """
       invalid schema given to NimbleOptions.validate/2. \
-      Reason: (in options [:producers, :keys, :*, :keys, :module]) \
+      Reason: \
       unknown options [:unknown_schema_option], \
       valid options are: [:type, :required, :default, :keys, \
-      :deprecated, :rename_to, :doc, :subsection]\
+      :deprecated, :rename_to, :doc, :subsection] \
+      (in options [:producers, :keys, :*, :keys, :module])\
       """
 
       assert_raise ArgumentError, message, fn ->
@@ -94,7 +100,10 @@ defmodule NimbleOptionsTest do
 
       assert NimbleOptions.validate(opts, schema) ==
                {:error,
-                "required option :name not found, received options: [:an_option, :other_option]"}
+                %ValidationError{
+                  message:
+                    "required option :name not found, received options: [:an_option, :other_option]"
+                }}
     end
   end
 
@@ -168,10 +177,14 @@ defmodule NimbleOptionsTest do
       schema = [stages: [type: :pos_integer]]
 
       assert NimbleOptions.validate([stages: 0], schema) ==
-               {:error, "expected :stages to be a positive integer, got: 0"}
+               {:error,
+                %ValidationError{message: "expected :stages to be a positive integer, got: 0"}}
 
       assert NimbleOptions.validate([stages: :an_atom], schema) ==
-               {:error, "expected :stages to be a positive integer, got: :an_atom"}
+               {:error,
+                %ValidationError{
+                  message: "expected :stages to be a positive integer, got: :an_atom"
+                }}
     end
 
     test "valid non negative integer" do
@@ -185,10 +198,16 @@ defmodule NimbleOptionsTest do
       schema = [min_demand: [type: :non_neg_integer]]
 
       assert NimbleOptions.validate([min_demand: -1], schema) ==
-               {:error, "expected :min_demand to be a non negative integer, got: -1"}
+               {:error,
+                %ValidationError{
+                  message: "expected :min_demand to be a non negative integer, got: -1"
+                }}
 
       assert NimbleOptions.validate([min_demand: :an_atom], schema) ==
-               {:error, "expected :min_demand to be a non negative integer, got: :an_atom"}
+               {:error,
+                %ValidationError{
+                  message: "expected :min_demand to be a non negative integer, got: :an_atom"
+                }}
     end
 
     test "valid atom" do
@@ -201,7 +220,7 @@ defmodule NimbleOptionsTest do
       schema = [name: [type: :atom]]
 
       assert NimbleOptions.validate([name: 1], schema) ==
-               {:error, "expected :name to be an atom, got: 1"}
+               {:error, %ValidationError{message: "expected :name to be an atom, got: 1"}}
     end
 
     test "valid string" do
@@ -214,7 +233,7 @@ defmodule NimbleOptionsTest do
       schema = [doc: [type: :string]]
 
       assert NimbleOptions.validate([doc: :an_atom], schema) ==
-               {:error, "expected :doc to be an string, got: :an_atom"}
+               {:error, %ValidationError{message: "expected :doc to be an string, got: :an_atom"}}
     end
 
     test "valid boolean" do
@@ -231,7 +250,8 @@ defmodule NimbleOptionsTest do
       schema = [required: [type: :boolean]]
 
       assert NimbleOptions.validate([required: :an_atom], schema) ==
-               {:error, "expected :required to be an boolean, got: :an_atom"}
+               {:error,
+                %ValidationError{message: "expected :required to be an boolean, got: :an_atom"}}
     end
 
     test "valid timeout" do
@@ -253,13 +273,19 @@ defmodule NimbleOptionsTest do
       opts = [timeout: -1]
 
       assert NimbleOptions.validate(opts, schema) ==
-               {:error, "expected :timeout to be non-negative integer or :infinity, got: -1"}
+               {:error,
+                %ValidationError{
+                  message: "expected :timeout to be non-negative integer or :infinity, got: -1"
+                }}
 
       opts = [timeout: :invalid]
 
       assert NimbleOptions.validate(opts, schema) ==
                {:error,
-                "expected :timeout to be non-negative integer or :infinity, got: :invalid"}
+                %ValidationError{
+                  message:
+                    "expected :timeout to be non-negative integer or :infinity, got: :invalid"
+                }}
     end
 
     test "valid pid" do
@@ -272,7 +298,7 @@ defmodule NimbleOptionsTest do
       schema = [name: [type: :pid]]
 
       assert NimbleOptions.validate([name: 1], schema) ==
-               {:error, "expected :name to be a pid, got: 1"}
+               {:error, %ValidationError{message: "expected :name to be a pid, got: 1"}}
     end
 
     test "valid mfa" do
@@ -292,28 +318,39 @@ defmodule NimbleOptionsTest do
 
       assert NimbleOptions.validate(opts, schema) == {
                :error,
-               ~s(expected :transformer to be a tuple {Mod, Fun, Args}, got: {"not_a_module", :func, []})
+               %ValidationError{
+                 message:
+                   ~s(expected :transformer to be a tuple {Mod, Fun, Args}, got: {"not_a_module", :func, []})
+               }
              }
 
       opts = [transformer: {SomeMod, "not_a_func", []}]
 
       assert NimbleOptions.validate(opts, schema) == {
                :error,
-               ~s(expected :transformer to be a tuple {Mod, Fun, Args}, got: {SomeMod, "not_a_func", []})
+               %ValidationError{
+                 message:
+                   ~s(expected :transformer to be a tuple {Mod, Fun, Args}, got: {SomeMod, "not_a_func", []})
+               }
              }
 
       opts = [transformer: {SomeMod, :func, "not_a_list"}]
 
       assert NimbleOptions.validate(opts, schema) == {
                :error,
-               ~s(expected :transformer to be a tuple {Mod, Fun, Args}, got: {SomeMod, :func, "not_a_list"})
+               %ValidationError{
+                 message:
+                   ~s(expected :transformer to be a tuple {Mod, Fun, Args}, got: {SomeMod, :func, "not_a_list"})
+               }
              }
 
       opts = [transformer: NotATuple]
 
       assert NimbleOptions.validate(opts, schema) == {
                :error,
-               ~s(expected :transformer to be a tuple {Mod, Fun, Args}, got: NotATuple)
+               %ValidationError{
+                 message: ~s(expected :transformer to be a tuple {Mod, Fun, Args}, got: NotATuple)
+               }
              }
     end
 
@@ -334,14 +371,19 @@ defmodule NimbleOptionsTest do
 
       assert NimbleOptions.validate(opts, schema) == {
                :error,
-               ~s(expected :producer to be a tuple {Mod, Arg}, got: NotATuple)
+               %ValidationError{
+                 message: ~s(expected :producer to be a tuple {Mod, Arg}, got: NotATuple)
+               }
              }
 
       opts = [producer: {"not_a_module", []}]
 
       assert NimbleOptions.validate(opts, schema) == {
                :error,
-               ~s(expected :producer to be a tuple {Mod, Arg}, got: {"not_a_module", []})
+               %ValidationError{
+                 message:
+                   ~s(expected :producer to be a tuple {Mod, Arg}, got: {"not_a_module", []})
+               }
              }
     end
 
@@ -362,14 +404,19 @@ defmodule NimbleOptionsTest do
 
       assert NimbleOptions.validate(opts, schema) == {
                :error,
-               ~s(expected :partition_by to be a function of arity 1, got: :not_a_fun)
+               %ValidationError{
+                 message: ~s(expected :partition_by to be a function of arity 1, got: :not_a_fun)
+               }
              }
 
       opts = [partition_by: fn x, y -> x * y end]
 
       assert NimbleOptions.validate(opts, schema) == {
                :error,
-               ~s(expected :partition_by to be a function of arity 1, got: function of arity 2)
+               %ValidationError{
+                 message:
+                   ~s(expected :partition_by to be a function of arity 1, got: function of arity 2)
+               }
              }
     end
 
@@ -389,7 +436,10 @@ defmodule NimbleOptionsTest do
       opts = [batch_mode: :invalid]
 
       assert NimbleOptions.validate(opts, schema) ==
-               {:error, "expected :batch_mode to be one of [:flush, :bulk], got: :invalid"}
+               {:error,
+                %ValidationError{
+                  message: "expected :batch_mode to be one of [:flush, :bulk], got: :invalid"
+                }}
     end
 
     test "{:custom, mod, fun, args} with empty args" do
@@ -405,7 +455,7 @@ defmodule NimbleOptionsTest do
 
       assert NimbleOptions.validate(opts, schema) == {
                :error,
-               ~s(expected :first or :last, got: :unknown)
+               %ValidationError{message: ~s(expected :first or :last, got: :unknown)}
              }
     end
 
@@ -422,7 +472,7 @@ defmodule NimbleOptionsTest do
 
       assert NimbleOptions.validate(opts, schema) == {
                :error,
-               ~s(expected one of [:first, :last], got: :unknown)
+               %ValidationError{message: ~s(expected one of [:first, :last], got: :unknown)}
              }
     end
 
@@ -474,7 +524,11 @@ defmodule NimbleOptionsTest do
 
       assert NimbleOptions.validate(opts, schema) ==
                {:error,
-                "(in options [:processors]) unknown options [:unknown_option1, :unknown_option2], valid options are: [:stages, :min_demand]"}
+                %ValidationError{
+                  keys_path: [:processors],
+                  message:
+                    "unknown options [:unknown_option1, :unknown_option2], valid options are: [:stages, :min_demand]"
+                }}
     end
 
     test "options with default values" do
@@ -523,7 +577,10 @@ defmodule NimbleOptionsTest do
 
       assert NimbleOptions.validate(opts, schema) ==
                {:error,
-                "(in options [:processors]) required option :stages not found, received options: [:max_demand]"}
+                %ValidationError{
+                  keys_path: [:processors],
+                  message: "required option :stages not found, received options: [:max_demand]"
+                }}
     end
 
     test "nested options types" do
@@ -541,7 +598,10 @@ defmodule NimbleOptionsTest do
 
       assert NimbleOptions.validate(opts, schema) ==
                {:error,
-                "(in options [:processors]) expected :stages to be a positive integer, got: :an_atom"}
+                %ValidationError{
+                  keys_path: [:processors],
+                  message: "expected :stages to be a positive integer, got: :an_atom"
+                }}
     end
   end
 
@@ -587,7 +647,10 @@ defmodule NimbleOptionsTest do
 
       assert NimbleOptions.validate(opts, schema) ==
                {:error,
-                "(in options [:producers, :producer1]) unknown options [:unknown_option], valid options are: [:module, :arg]"}
+                %ValidationError{
+                  keys_path: [:producers, :producer1],
+                  message: "unknown options [:unknown_option], valid options are: [:module, :arg]"
+                }}
     end
 
     test "options with default values" do
@@ -651,7 +714,10 @@ defmodule NimbleOptionsTest do
 
       assert NimbleOptions.validate(opts, schema) ==
                {:error,
-                "(in options [:producers, :default]) required option :arg not found, received options: [:module]"}
+                %ValidationError{
+                  keys_path: [:producers, :default],
+                  message: "required option :arg not found, received options: [:module]"
+                }}
     end
 
     test "nested options types" do
@@ -681,7 +747,10 @@ defmodule NimbleOptionsTest do
 
       assert NimbleOptions.validate(opts, schema) ==
                {:error,
-                "(in options [:producers, :producer1]) expected :stages to be a positive integer, got: :an_atom"}
+                %ValidationError{
+                  keys_path: [:producers, :producer1],
+                  message: "expected :stages to be a positive integer, got: :an_atom"
+                }}
     end
 
     test "validate empty keys for :non_empty_keyword_list" do
@@ -705,7 +774,10 @@ defmodule NimbleOptionsTest do
       ]
 
       assert NimbleOptions.validate(opts, schema) ==
-               {:error, "expected :producers to be a non-empty keyword list, got: []"}
+               {:error,
+                %ValidationError{
+                  message: "expected :producers to be a non-empty keyword list, got: []"
+                }}
     end
 
     test "allow empty keys for :keyword_list" do
@@ -774,7 +846,10 @@ defmodule NimbleOptionsTest do
 
       assert NimbleOptions.validate(opts, schema) ==
                {:error,
-                "(in options [:socket_options, :certificates]) expected :path to be an string, got: :not_a_string"}
+                %ValidationError{
+                  keys_path: [:socket_options, :certificates],
+                  message: "expected :path to be an string, got: :not_a_string"
+                }}
     end
   end