Repository: dashbitco/nimble_options
Pull request #90 (2022-08-18 21:00:31)

Add support for maps
Hello! This pr is not in any mergable state - but wanted to start a conversation about adding support for maps as both a schema and option definition.  This "implementation" (if you can call it that) seems to add that support for the cases I have tried (needs to be extended to cover the docs functionality I believe, but we can get to that!).

Before moving forward with cleaning up / adding additional test I wanted to get a few set of eyes on it to see if this was something you were interested in / your general thoughts.

Thanks!

whatyouhide: @camilleryr thanks for the proposal! What's the use case or advantage for using maps as a schema definition?
camilleryr: > @camilleryr thanks for the proposal! What's the use case or advantage for using maps as a schema definition?

At work we have adopted NimbleOptions as our specification language for for parts of our system.

We have a generic data structure that is key to our application, and lots of different things we can do to that data structure, and the contract for all of those functions ends up as `@callback process(data_structure(), keyowrd()) :: {:ok, any()} | {:error, any()}`

Using NimbleOptions for specifying these options is already great - we get documentation, validation, and with an implementation of the Phoenix.HTML.FormData we get an ability to auto generate forms based on our schemas

The reason we wish to extend NimbleOption is that other parts of our system are starting to have the same basic needs of data specification, validation and form generation.  More often than not we would reach for a map as the shape of this data rather then a keyword list, but currently cannot use any of the functionalities we have built out using NimbleOptions without conversions back and forth between maps and keyword lists.

As fas as the advantages of using maps strictly as the `schema definition` (rather than just being able to use keyword lists to specify maps) - thats not something I have a particularly strong feeling about, but the choice made some sense based on the current implementation.

Currently NimbleOptions enforces that data to be validated against a shcema is a keyword list.  Its easy enough to allow maps to be validated against a schema, and to specify `:map` as an additional built in type, but there is not currently a concept for is specifying the shape of the outer layer of the data which means that would end up being able to validate *either* a keyword list or a map that met the specification.

```elixir
      schema = [required: [type: :boolean]]]

      list_opts = [required: true]
      assert NimbleOptions.validate(list_opts, schema) == {:ok, list_opts}

      map_opts = %{required: true}
      assert NimbleOptions.validate(map_opts, schema) == {:ok, map_opts}
```

My approach (which I didn't put that much thought into, and would be happy to change!) was that without changing the semantics of the specification, we could use maps to specify maps and lists to specify lists.

```elixir
      schema = %{required: [type: :boolean]}

      map_opts = %{required: true}
      assert NimbleOptions.validate(map_opts, schema) == {:ok, map_opts}

      list_opts = [required: true]
      assert_raise(FunctionClauseError, fn -> NimbleOptions.validate(list_opts, schema) == {:ok, list_opts} end)
```

This extension came up as part of a review call with dashbit, so @josevalim  and @wojtekmach might have additional thoughts on our use of NimbleOptions and the potential to extend it to specify maps
josevalim: I think there are two discussions here:

1. Support maps at the root
2. Support schemas as maps

I think 1 makes sense, perhaps via an option in the schema that specifies the root. But I don't see a reason for 2 and there are benefits for keyword lists in DSLs/specifications (preserving order, allowing duplicates, etc).
josevalim: And hi @camilleryr! 👋 Thanks for the PR!
whatyouhide: I see 1. as being okay too, we're just adding a supported data type. As for 2., I agree with José. We'll end up polluting nimble_options beyond recognition, I’m afraid 😄 As the name of the library says, it's really concerned only with **options**. What you are looking for is more complex and I think other tools would serve you better. One of them could be Ecto maybe.
josevalim: And 2 should be a matter of calling `Map.to_list/1` if you indeed have a map, right?
camilleryr: I think using the keyword list to write the schemas makes sense, and adding support for maps as a type and some kind of option to specify the root type would cover all of the things we need!

I have updated my branch to reflect that - the tests are still in need of work - but wanted to get your thoughts on this approach!  Right now I have added a `__root_type__` (I wanted something that would have little risk of collision - but Im open to any name for this! I also though of using $ since that is often used as the root in json path languages) option that can only be applied at the outermost level of a schema to specify if a map or keyword list is expected.  To keep from introducing any breaking changes, if no value is provided, we will infer that the `__root_type__` is a `keyword_list`
josevalim: Oh, I forgot that we can't specify options at the root. I understand why we need `__root_type__` but it may not be ideal.

Maybe we could just allow `validate` to receive a map too? Or maybe we introduce another function explicitly for maps?
camilleryr: > Oh, I forgot that we can't specify options at the root. I understand why we need `__root_type__` but it may not be ideal.
> 
> Maybe we could just allow `validate` to receive a map too? Or maybe we introduce another function explicitly for maps?

I agree that the `__root_type__` option was a bit awkward for a number of reasons.  I think the only real benefit is that allowing us to specify the outer structure allows us to be very explicit in the specification, so the call sites don't need to be aware.

Allowing `validate` to take a map as well as a list is a various is a fine approach, but without the ability to validate the outer shape we could introduce some breaking changes for existing uses.

Then we could have validate_list and validate_map functions, or a validate/3 function that allows for some kind of optional `root_type` configuration - which would allow us to do all the things we need, but removes that as part of the specification and puts the responsibility on the caller to know what shape its supposed to be.
josevalim: We have two options it seems:

1. Make `validate/2` work for both, add `validate_kw` and `validate_map` for strict

2. Add `validate(kw_or_map, schema, root_type: :map | :keyword)`, but I am not the biggest fan of options having an effect in the function signature

So my vote is for 1 and we do have guards around `validate/2`, so it should not be a breaking change per se.

Thoughts @whatyouhide?
whatyouhide: I was thinking of the schema being a map, not the data being validated, so I had got this all wrong. Sorry about that. I’m still leaning on the opinion that validating maps through NimbleOptions is not what NimbleOptions is for. For example, Ecto schemas provide generally more flexibility (and a much wider ecosystem of tools) for doing something like that. In a pinch, you can `Map.to_list(map)` and validate that I guess? 🤔 
camilleryr: While Ecto does offer a lot of great things - we decided to pursue NimbleOptions as it more closely aligned with what our needs are, and with the addition of `map` as a built in type, we are much closer to having all of our needs met!

While we could very easily make sure that we are calling `Map.to_list` on a map we are trying to validate, and calling `Map.new()` on the validated result - to me it seems this seems like an extension to NimbleOptions that does not change its purpose, but allows it fulfill that purpose for more use cases (while the convention for options to be a keyword list exists, its not a requirement, and often maps are more ergonomic depending on the particular use case!)

If you are sure you don't think this functionality belongs in NimbleOptions I will happily revert all of those changes and just PR the inclusion of `map` as a type, but I really do think that being able to natively specify and validate maps is a valuable addition!

And most importantly - thanks for all of the work you all do!

* Additionally, in regards to Ecto - one issue is that a `schema` is backed by a `map`, so if we wanted to specify both keyword lists and maps we would be in the same boat!
josevalim: > While we could very easily make sure that we are calling Map.to_list on a map we are trying to validate, and calling Map.new() on the validated result

It may be worth benchmarking because I am thinking converting the map to list and list to a map may be the most efficient implementation (and also the simplest) and I personally wouldn't mind supporting such in NimbleOptions.
camilleryr: > > While we could very easily make sure that we are calling Map.to_list on a map we are trying to validate, and calling Map.new() on the validated result
> 
> It may be worth benchmarking because I am thinking converting the map to list and list to a map may be the most efficient implementation (and also the simplest) and I personally wouldn't mind supporting such in NimbleOptions.

I have rolled back all of the `root_type` work and instead have added a new function clause to validate that will convert a top level map into a list, validate that agains a schema and then then covert the returned keyword list back into a map.
camilleryr: @josevalim - at your suggestion I ran some benchmarks against the ["delegating" code](https://github.com/dashbitco/nimble_options/commit/fb45f45f63f0a73487465583c219dc5aec4f618f) (runtime delegation of functions that work on keyword_lists and maps) vs the ["conversion" code](https://github.com/dashbitco/nimble_options/commit/2d7a55f99b2a629afae34484643011e87af009ec) (explicitly turning all maps into lists, validating them, and then converting them back) and you were correct!  Conversion is faster for the scenarios I checked!

```
Name                 ips        average  deviation         median         99th %
conversion       48.61 K       20.57 μs    ±39.22%          18 μs          55 μs
delegated        42.33 K       23.62 μs    ±39.15%          21 μs          61 μs

Comparison:
conversion       48.61 K
delegated        42.33 K - 1.15x slower +3.05 μs
```
josevalim: If conversion is faster, then perhaps there is nothing to change here? You could wrap nimble options and do the conversion within your application and additionally ensure anything else you may need to? :)
camilleryr: > If conversion is faster, then perhaps there is nothing to change here? You could wrap nimble options and do the conversion within your application and additionally ensure anything else you may need to? :)

We can and do have an internal wrapper that we can use to enforce the "outer layer" conversion of map to lists and back, but it does require changes inside NimbleOptions to support `map` as a new data type.  I have removed the additional guard around `validate/2` so now everything should work the same way but we should now be able to support map as internal data structures. 
camilleryr: I have extended map support into `:or` and `:list` types and added tests to cover all the existing nested schema tests for the `map` type as well.  I think this covers everything - let me know if you know of anywhere else we would need to exted to cover `map` and if you have any more thoughts!

Thank you @josevalim and @whatyouhide for your time helping me get this sorted out!

Patch:
diff --git a/lib/nimble_options.ex b/lib/nimble_options.ex
index de73bce..4826888 100644
--- a/lib/nimble_options.ex
+++ b/lib/nimble_options.ex
@@ -24,7 +24,7 @@ defmodule NimbleOptions do
         keys: [
           type: :keyword_list,
           doc: """
-          Available for types `:keyword_list` and `:non_empty_keyword_list`,
+          Available for types `:keyword_list`, `:non_empty_keyword_list`, and `:map`,
           it defines which set of keys are accepted for the option item. The value of the
           `:keys` option is a schema itself. For example: `keys: [foo: [type: :atom]]`.
           Use `:*` as the key to allow multiple arbitrary keys and specify their schema:
@@ -96,6 +96,12 @@ defmodule NimbleOptions do
 
     * `:non_empty_keyword_list` - A non-empty keyword list.
 
+    * `:map` - A map consisting of `:atom` keys. Shorthand for `{:map, :atom, :any}`.
+      Keys can be specified using the `keys` option.
+
+    * `{:map, key_type, value_type}` - A map consisting of `key_type` keys and
+      `value_type` values.
+
     * `:atom` - An atom.
 
     * `:string` - A string.
@@ -138,9 +144,9 @@ defmodule NimbleOptions do
       matched against the subtypes in the order specified in the list of `subtypes`. If
       one of the subtypes matches and **updates** (casts) the given value, the updated
       value is used. For example: `{:or, [:string, :boolean, {:fun, 2}]}`. If one of the
-      subtypes is a keyword list, you won't be able to pass `:keys` directly. For this reason,
-      keyword lists (`:keyword_list` and `:non_empty_keyword_list`) are special cased and can
-      be used as subtypes with `{:keyword_list, keys}` or `{:non_empty_keyword_list, keys}`.
+      subtypes is a keyword list or map, you won't be able to pass `:keys` directly. For this reason,
+      `:keyword_list`, `:non_empty_keyword_list`, and `:map` are special cased and can
+      be used as subtypes with `{:keyword_list, keys}`, `{:non_empty_keyword_list, keys}` or `{:map, keys}`.
       For example, a type such as `{:or, [:boolean, keyword_list: [enabled: [type: :boolean]]]}`
       would match either a boolean or a keyword list with the `:enabled` boolean option in it.
 
@@ -150,10 +156,10 @@ defmodule NimbleOptions do
       through `subtype`. For example, if `subtype` is a custom validator function that returns
       an updated value, then that updated value is used in the resulting list. Validation
       fails at the *first* element that is invalid according to `subtype`. If `subtype` is
-      a keyword list, you won't be able to pass `:keys` directly. For this reason,
-      keyword lists (`:keyword_list` and `:non_empty_keyword_list`) are special cased and can
-      be used as the subtype by using `{:keyword_list, keys}` or
-      `{:non_empty_keyword_list, keys}`. For example, a type such as
+      a keyword list or map, you won't be able to pass `:keys` directly. For this reason,
+      `:keyword_list`, `:non_empty_keyword_list`, and `:map` are special cased and can
+      be used as the subtype by using `{:keyword_list, keys}`, `{:non_empty_keyword_list, keys}`
+      or `{:keyword_list, keys}`. For example, a type such as
       `{:list, {:keyword_list, enabled: [type: :boolean]}}` would a *list of keyword lists*,
       where each keyword list in the list could have the `:enabled` boolean option in it.
 
@@ -265,6 +271,7 @@ defmodule NimbleOptions do
     :any,
     :keyword_list,
     :non_empty_keyword_list,
+    :map,
     :atom,
     :integer,
     :non_neg_integer,
@@ -458,7 +465,16 @@ defmodule NimbleOptions do
     validate_options_with_schema_and_path(opts, fun.(), path)
   end
 
-  defp validate_options_with_schema_and_path(opts, schema, path) do
+  defp validate_options_with_schema_and_path(opts, schema, path) when is_map(opts) do
+    list_opts = Map.to_list(opts)
+
+    case validate_options_with_schema_and_path(list_opts, schema, path) do
+      {:ok, validated_list_opts} -> {:ok, Map.new(validated_list_opts)}
+      error -> error
+    end
+  end
+
+  defp validate_options_with_schema_and_path(opts, schema, path) when is_list(opts) do
     schema = expand_star_to_option_keys(schema, opts)
 
     with :ok <- validate_unknown_options(opts, schema),
@@ -643,6 +659,21 @@ defmodule NimbleOptions do
     end
   end
 
+  defp validate_type(:map, key, value) do
+    validate_type({:map, :atom, :any}, key, value)
+  end
+
+  defp validate_type({:map, key_type, value_type}, key, value) when is_map(value) do
+    with {:ok, _keys} <- validate_map_keys(key_type, key, value),
+         {:ok, _values} <- validate_map_values(value_type, key, value) do
+      {:ok, value}
+    end
+  end
+
+  defp validate_type({:map, _, _}, key, value) do
+    error_tuple(key, value, "expected #{inspect(key)} to be a map, got: #{inspect(value)}")
+  end
+
   defp validate_type(:pid, _key, value) when is_pid(value) do
     {:ok, value}
   end
@@ -745,8 +776,8 @@ defmodule NimbleOptions do
       Enum.reduce_while(subtypes, _errors = [], fn subtype, errors_acc ->
         {subtype, nested_schema} =
           case subtype do
-            {keyword_list, keys} when keyword_list in [:keyword_list, :non_empty_keyword_list] ->
-              {keyword_list, keys}
+            {type, keys} when type in [:keyword_list, :non_empty_keyword_list, :map] ->
+              {type, keys}
 
             other ->
               {other, _nested_schema = nil}
@@ -784,8 +815,8 @@ defmodule NimbleOptions do
   defp validate_type({:list, subtype}, key, value) when is_list(value) do
     {subtype, nested_schema} =
       case subtype do
-        {keyword_list, keys} when keyword_list in [:keyword_list, :non_empty_keyword_list] ->
-          {keyword_list, keys}
+        {type, keys} when type in [:keyword_list, :non_empty_keyword_list, :map] ->
+          {type, keys}
 
         other ->
           {other, _nested_schema = nil}
@@ -895,7 +926,8 @@ defmodule NimbleOptions do
           "{:or, subtypes}",
           "{:custom, mod, fun, args}",
           "{:list, subtype}",
-          "{:tuple, list_of_subtypes}"
+          "{:tuple, list_of_subtypes}",
+          "{:map, key_type, value_type}"
         ]
 
     Enum.join(types, ", ")
@@ -922,8 +954,8 @@ defmodule NimbleOptions do
 
   def validate_type({:or, subtypes} = value) when is_list(subtypes) do
     Enum.reduce_while(subtypes, {:ok, value}, fn
-      {keyword_list_type, _keys}, acc
-      when keyword_list_type in [:keyword_list, :non_empty_keyword_list] ->
+      {type, _keys}, acc
+      when type in [:keyword_list, :non_empty_keyword_list, :map] ->
         {:cont, acc}
 
       subtype, acc ->
@@ -937,7 +969,7 @@ defmodule NimbleOptions do
   # This is to support the special-cased "{:list, {:keyword_list, my_key: [type: ...]}}",
   # like we do in the :or type.
   def validate_type({:list, {type, keys}})
-      when type in [:keyword_list, :non_empty_keyword_list] and is_list(keys) do
+      when type in [:keyword_list, :non_empty_keyword_list, :map] and is_list(keys) do
     {:ok, {:list, {type, keys}}}
   end
 
@@ -962,10 +994,47 @@ defmodule NimbleOptions do
     {:error, reason} -> {:error, reason}
   end
 
+  def validate_type({:map, key_type, value_type}) do
+    valid_key_type =
+      case validate_type(key_type) do
+        {:ok, validated_key_type} -> validated_key_type
+        {:error, reason} -> throw({:error, "invalid key_type for :map type: #{reason}"})
+      end
+
+    valid_values_type =
+      case validate_type(value_type) do
+        {:ok, validated_values_type} -> validated_values_type
+        {:error, reason} -> throw({:error, "invalid value_type for :map type: #{reason}"})
+      end
+
+    {:ok, {:map, valid_key_type, valid_values_type}}
+  catch
+    {:error, reason} -> {:error, reason}
+  end
+
   def validate_type(value) do
     {:error, "unknown type #{inspect(value)}.\n\nAvailable types: #{available_types()}"}
   end
 
+  defp validate_map_keys(type, key, value), do: validate_all(type, key, Map.keys(value), "key")
+
+  defp validate_map_values(type, key, value),
+    do: validate_all(type, key, Map.values(value), "value")
+
+  defp validate_all(type, key, values, attribute) when is_list(values) do
+    case Enum.reject(values, &match?({:ok, _}, validate_type(type, key, &1))) do
+      [] ->
+        {:ok, values}
+
+      unvalidated ->
+        error_tuple(
+          key,
+          values,
+          "expected #{inspect(key)} to have #{attribute}s of type #{inspect(type)}, got: #{Enum.map_join(unvalidated, ", and ", &inspect/1)}"
+        )
+    end
+  end
+
   defp error_tuple(key, value, message) do
     {:error, %ValidationError{key: key, message: message, value: value}}
   end
diff --git a/lib/nimble_options/docs.ex b/lib/nimble_options/docs.ex
index e0750f0..0606c21 100644
--- a/lib/nimble_options/docs.ex
+++ b/lib/nimble_options/docs.ex
@@ -125,11 +125,19 @@ defmodule NimbleOptions.Docs do
   defp get_raw_type_str(:non_empty_keyword_list), do: "non-empty `t:keyword/0`"
   defp get_raw_type_str({:keyword_list, _keys}), do: "`t:keyword/0`"
   defp get_raw_type_str({:non_empty_keyword_list, _keys}), do: "non-empty `t:keyword/0`"
+  defp get_raw_type_str(:map), do: "`t:map/0`"
 
   defp get_raw_type_str({:list, subtype}) do
     if subtype_str = get_raw_type_str(subtype), do: "list of #{subtype_str}"
   end
 
+  defp get_raw_type_str({:map, key_type, value_type}) do
+    with key_type_str when is_binary(key_type_str) <- get_raw_type_str(key_type),
+         value_type_str when is_binary(value_type_str) <- get_raw_type_str(value_type) do
+      "map of #{key_type_str} keys and #{value_type_str} values"
+    end
+  end
+
   defp indent_doc(text, indent) do
     [head | tail] = String.split(text, ["\r\n", "\n"])
 


Test patch:
diff --git a/test/nimble_options_test.exs b/test/nimble_options_test.exs
index 2fac517..e3631ab 100644
--- a/test/nimble_options_test.exs
+++ b/test/nimble_options_test.exs
@@ -39,10 +39,10 @@ defmodule NimbleOptionsTest do
       invalid NimbleOptions schema. \
       Reason: invalid value for :type option: unknown type :foo.
 
-      Available types: :any, :keyword_list, :non_empty_keyword_list, :atom, \
+      Available types: :any, :keyword_list, :non_empty_keyword_list, :map, :atom, \
       :integer, :non_neg_integer, :pos_integer, :float, :mfa, :mod_arg, :string, :boolean, :timeout, \
       :pid, :reference, {:fun, arity}, {:in, choices}, {:or, subtypes}, {:custom, mod, fun, args}, \
-      {:list, subtype}, {:tuple, list_of_subtypes} \
+      {:list, subtype}, {:tuple, list_of_subtypes}, {:map, key_type, value_type} \
       (in options [:stages])\
       """
 
@@ -724,6 +724,20 @@ defmodule NimbleOptionsTest do
       assert NimbleOptions.validate(opts, schema) == {:ok, opts}
     end
 
+    test "valid {:or, subtypes} with nested map" do
+      schema = [
+        docs: [
+          type: {:or, [:boolean, map: [enabled: [type: :boolean]]]}
+        ]
+      ]
+
+      opts = [docs: false]
+      assert NimbleOptions.validate(opts, schema) == {:ok, opts}
+
+      opts = [docs: %{enabled: true}]
+      assert NimbleOptions.validate(opts, schema) == {:ok, opts}
+    end
+
     test "invalid {:or, subtypes}" do
       schema = [docs: [type: {:or, [:string, :boolean]}]]
 
@@ -1015,6 +1029,33 @@ defmodule NimbleOptionsTest do
       end
     end
 
+    test "{:list, subtype} with a :map subtype" do
+      type = {:custom, __MODULE__, :string_to_integer, []}
+      schema = [map_list: [type: {:list, {:map, str: [type: type]}}]]
+
+      valid_opts = [map_list: [%{str: "1"}, %{str: "2"}]]
+
+      assert NimbleOptions.validate(valid_opts, schema) ==
+               {:ok, [map_list: [%{str: 1}, %{str: 2}]]}
+
+      invalid_opts = [map_list: [%{str: "123"}, %{str: "not an int"}]]
+
+      message = """
+      list element at position 1 in :map_list failed validation: expected string to be \
+      convertible to integer\
+      """
+
+      assert NimbleOptions.validate(invalid_opts, schema) == {
+               :error,
+               %NimbleOptions.ValidationError{
+                 key: :map_list,
+                 keys_path: [],
+                 message: message,
+                 value: [%{str: "123"}, %{str: "not an int"}]
+               }
+             }
+    end
+
     test "valid {:tuple, tuple_def}" do
       schema = [result: [type: {:tuple, [{:in, [:ok, :error]}, :string]}]]
 
@@ -1081,297 +1122,451 @@ defmodule NimbleOptionsTest do
                }
              }
     end
-  end
 
-  describe "nested options with predefined keys" do
-    test "known options" do
-      schema = [
-        processors: [
-          type: :keyword_list,
-          keys: [
-            stages: [],
-            max_demand: []
-          ]
-        ]
-      ]
+    test "valid :map" do
+      schema = [map: [type: :map]]
+
+      opts = [map: %{}]
+      assert NimbleOptions.validate(opts, schema) == {:ok, opts}
+
+      opts = [map: %{atom_key: :value}]
+      assert NimbleOptions.validate(opts, schema) == {:ok, opts}
+
+      schema = [map: [type: :map, keys: [key: [type: :string]]]]
 
-      opts = [processors: [stages: 1, max_demand: 2]]
+      opts = [map: %{}]
+      assert NimbleOptions.validate(opts, schema) == {:ok, opts}
 
+      opts = [map: %{key: "string"}]
       assert NimbleOptions.validate(opts, schema) == {:ok, opts}
     end
 
-    test "unknown options" do
-      schema = [
-        processors: [
-          type: :keyword_list,
-          keys: [
-            stages: [],
-            min_demand: []
-          ]
-        ]
-      ]
+    test "invalid :map" do
+      schema = [map: [type: :map]]
 
-      opts = [
-        processors: [
-          stages: 1,
-          unknown_option1: 1,
-          unknown_option2: 1
-        ]
-      ]
+      opts = [map: %{"string key" => :value}]
 
       assert NimbleOptions.validate(opts, schema) ==
                {:error,
-                %ValidationError{
-                  key: [:unknown_option1, :unknown_option2],
-                  keys_path: [:processors],
+                %NimbleOptions.ValidationError{
+                  key: :map,
+                  keys_path: [],
+                  message: "expected :map to have keys of type :atom, got: \"string key\"",
+                  value: ["string key"]
+                }}
+
+      schema = [map: [type: :map, keys: [key: [type: :string]]]]
+
+      opts = [map: %{key: :atom_value}]
+
+      assert NimbleOptions.validate(opts, schema) ==
+               {:error,
+                %NimbleOptions.ValidationError{
+                  key: :key,
+                  keys_path: [:map],
+                  message: "expected :key to be a string, got: :atom_value",
+                  value: :atom_value
+                }}
+
+      opts = [map: %{unknown_key: "string"}]
+
+      assert NimbleOptions.validate(opts, schema) ==
+               {:error,
+                %NimbleOptions.ValidationError{
+                  __exception__: true,
+                  key: [:unknown_key],
+                  keys_path: [:map],
+                  message: "unknown options [:unknown_key], valid options are: [:key]",
+                  value: nil
+                }}
+    end
+
+    test "valid {:map, key_type, value_type}" do
+      schema = [map: [type: {:map, :string, :string}]]
+
+      opts = [map: %{"valid_key" => "valid_value", "other_key" => "other_value"}]
+      assert NimbleOptions.validate(opts, schema) == {:ok, opts}
+
+      opts = [map: %{}]
+      assert NimbleOptions.validate(opts, schema) == {:ok, opts}
+
+      schema = [map: [type: {:map, {:in, [:a, :b, :c]}, {:list, :integer}}]]
+
+      opts = [map: %{a: [1, 2, 3], c: [4, 5, 6]}]
+      assert NimbleOptions.validate(opts, schema) == {:ok, opts}
+
+      schema = [map: [type: {:map, :any, :any}]]
+
+      opts = [map: %{%{map: :key} => {:and, :map, :value}}]
+      assert NimbleOptions.validate(opts, schema) == {:ok, opts}
+    end
+
+    test "invalid {:map, key_type, value_type}" do
+      schema = [map: [type: {:map, :string, :string}]]
+
+      opts = [map: %{:invalid_key => "valid_value", :other_invalid_key => "other_value"}]
+
+      assert NimbleOptions.validate(opts, schema) ==
+               {:error,
+                %NimbleOptions.ValidationError{
+                  key: :map,
+                  keys_path: [],
+                  message:
+                    "expected :map to have keys of type :string, got: :invalid_key, and :other_invalid_key",
+                  value: [:invalid_key, :other_invalid_key]
+                }}
+
+      opts = [map: %{"valid_key" => :invalid_value, "other_key" => :other_invalid_value}]
+
+      assert NimbleOptions.validate(opts, schema) ==
+               {:error,
+                %NimbleOptions.ValidationError{
+                  key: :map,
+                  keys_path: [],
+                  message:
+                    "expected :map to have values of type :string, got: :other_invalid_value, and :invalid_value",
+                  value: [:other_invalid_value, :invalid_value]
+                }}
+
+      schema = [map: [type: {:map, {:in, [:a, :b, :c]}, {:list, :integer}}]]
+
+      opts = [map: %{invalid_key: [1, 2, 3], c: [4, 5, 6]}]
+
+      assert NimbleOptions.validate(opts, schema) ==
+               {:error,
+                %NimbleOptions.ValidationError{
+                  key: :map,
+                  keys_path: [],
                   message:
-                    "unknown options [:unknown_option1, :unknown_option2], valid options are: [:stages, :min_demand]"
+                    "expected :map to have keys of type {:in, [:a, :b, :c]}, got: :invalid_key",
+                  value: [:c, :invalid_key]
                 }}
     end
+  end
 
-    test "options with default values" do
-      schema = [
-        processors: [
-          type: :keyword_list,
-          keys: [
-            stages: [default: 10]
+  describe "nested options with predefined keys" do
+    for type <- [:keyword_list, :map] do
+      test "known options for #{type}" do
+        schema = [
+          processors: [
+            type: unquote(type),
+            keys: [
+              stages: [],
+              max_demand: []
+            ]
           ]
         ]
-      ]
 
-      opts = [processors: []]
+        processors = opts_to_type(unquote(type), stages: 1, max_demand: 2)
+        opts = [processors: processors]
+        assert NimbleOptions.validate(opts, schema) == {:ok, opts}
+      end
+    end
+
+    for type <- [:keyword_list, :map] do
+      test "unknown options for #{type}" do
+        schema = [
+          processors: [
+            type: unquote(type),
+            keys: [
+              stages: [],
+              min_demand: []
+            ]
+          ]
+        ]
+
+        processors =
+          opts_to_type(unquote(type), stages: 1, unknown_option1: 1, unknown_option2: 1)
+
+        opts = [processors: processors]
 
-      assert NimbleOptions.validate(opts, schema) == {:ok, [processors: [stages: 10]]}
+        assert NimbleOptions.validate(opts, schema) ==
+                 {:error,
+                  %ValidationError{
+                    key: [:unknown_option1, :unknown_option2],
+                    keys_path: [:processors],
+                    message:
+                      "unknown options [:unknown_option1, :unknown_option2], valid options are: [:stages, :min_demand]"
+                  }}
+      end
     end
 
-    test "empty default option with default values" do
-      schema = [
-        processors: [
-          type: :keyword_list,
-          default: [],
-          keys: [
-            stages: [default: 10]
+    for type <- [:keyword_list, :map] do
+      test "options with default values for #{type}" do
+        schema = [
+          processors: [
+            type: unquote(type),
+            keys: [
+              stages: [default: 10]
+            ]
           ]
         ]
-      ]
 
-      assert NimbleOptions.validate([], schema) == {:ok, [processors: [stages: 10]]}
+        processors = opts_to_type(unquote(type), [])
+        opts = [processors: processors]
+
+        assert NimbleOptions.validate(opts, schema) ==
+                 {:ok, [processors: opts_to_type(unquote(type), stages: 10)]}
+      end
     end
 
-    test "all required options present" do
-      schema = [
-        processors: [
-          type: :keyword_list,
-          keys: [
-            stages: [required: true],
-            max_demand: [required: true]
+    for type <- [:keyword_list, :map] do
+      test "empty default option with default values for #{type}" do
+        schema = [
+          processors: [
+            type: unquote(type),
+            default: opts_to_type(unquote(type), []),
+            keys: [
+              stages: [default: 10]
+            ]
           ]
         ]
-      ]
 
-      opts = [processors: [stages: 1, max_demand: 2]]
+        processors = opts_to_type(unquote(type), stages: 10)
+        assert NimbleOptions.validate([], schema) == {:ok, [processors: processors]}
+      end
+    end
 
-      assert NimbleOptions.validate(opts, schema) == {:ok, opts}
+    for type <- [:keyword_list, :map] do
+      test "all required options present for type #{type}" do
+        schema = [
+          processors: [
+            type: unquote(type),
+            keys: [
+              stages: [required: true],
+              max_demand: [required: true]
+            ]
+          ]
+        ]
+
+        processors = opts_to_type(unquote(type), stages: 1, max_demand: 2)
+        opts = [processors: processors]
+
+        assert NimbleOptions.validate(opts, schema) == {:ok, opts}
+      end
     end
 
-    test "required options missing" do
-      schema = [
-        processors: [
-          type: :keyword_list,
-          keys: [
-            stages: [required: true],
-            max_demand: [required: true]
+    for type <- [:keyword_list, :map] do
+      test "required options missing for #{type}" do
+        schema = [
+          processors: [
+            type: unquote(type),
+            keys: [
+              stages: [required: true],
+              max_demand: [required: true]
+            ]
           ]
         ]
-      ]
 
-      opts = [processors: [max_demand: 1]]
+        processors = opts_to_type(unquote(type), max_demand: 1)
+        opts = [processors: processors]
 
-      assert NimbleOptions.validate(opts, schema) ==
-               {:error,
-                %ValidationError{
-                  key: :stages,
-                  keys_path: [:processors],
-                  message: "required option :stages not found, received options: [:max_demand]"
-                }}
+        assert NimbleOptions.validate(opts, schema) ==
+                 {:error,
+                  %ValidationError{
+                    key: :stages,
+                    keys_path: [:processors],
+                    message: "required option :stages not found, received options: [:max_demand]"
+                  }}
+      end
     end
 
-    test "nested options types" do
-      schema = [
-        processors: [
-          type: :keyword_list,
-          keys: [
-            name: [type: :atom],
-            stages: [type: :pos_integer]
+    for type <- [:keyword_list, :map] do
+      test "nested options types for #{type}" do
+        schema = [
+          processors: [
+            type: unquote(type),
+            keys: [
+              name: [type: :atom],
+              stages: [type: :pos_integer]
+            ]
           ]
         ]
-      ]
 
-      opts = [processors: [name: MyModule, stages: :an_atom]]
+        processors = opts_to_type(unquote(type), name: MyModule, stages: :an_atom)
+        opts = [processors: processors]
 
-      assert NimbleOptions.validate(opts, schema) ==
-               {:error,
-                %ValidationError{
-                  key: :stages,
-                  value: :an_atom,
-                  keys_path: [:processors],
-                  message:
-                    "invalid value for :stages option: expected positive integer, got: :an_atom"
-                }}
+        assert NimbleOptions.validate(opts, schema) ==
+                 {:error,
+                  %ValidationError{
+                    key: :stages,
+                    value: :an_atom,
+                    keys_path: [:processors],
+                    message: "invalid value for :stages option: expected positive integer, got: :an_atom"
+                  }}
+      end
     end
   end
 
   describe "nested options with custom keys" do
-    test "known options" do
-      schema = [
-        producers: [
-          type: :keyword_list,
-          keys: [
-            *: [
-              type: :keyword_list,
-              keys: [
-                module: [],
-                arg: [type: :atom]
+    for type <- [:keyword_list, :map] do
+      test "known options for #{type}" do
+        schema = [
+          producers: [
+            type: unquote(type),
+            keys: [
+              *: [
+                type: unquote(type),
+                keys: [
+                  module: [],
+                  arg: [type: :atom]
+                ]
               ]
             ]
           ]
         ]
-      ]
 
-      opts = [producers: [producer1: [module: MyModule, arg: :atom]]]
+        producer1 = opts_to_type(unquote(type), module: MyModule, arg: :atom)
+        producers = opts_to_type(unquote(type), producer1: producer1)
+        opts = [producers: producers]
 
-      assert NimbleOptions.validate(opts, schema) == {:ok, opts}
+        assert NimbleOptions.validate(opts, schema) == {:ok, opts}
+      end
     end
 
-    test "unknown options" do
-      schema = [
-        producers: [
-          type: :keyword_list,
-          keys: [
-            *: [
-              type: :keyword_list,
-              keys: [
-                module: [],
-                arg: []
+    for type <- [:keyword_list, :map] do
+      test "unknown options for #{type}" do
+        schema = [
+          producers: [
+            type: unquote(type),
+            keys: [
+              *: [
+                type: unquote(type),
+                keys: [
+                  module: [],
+                  arg: []
+                ]
               ]
             ]
           ]
         ]
-      ]
 
-      opts = [producers: [producer1: [module: MyModule, arg: :ok, unknown_option: 1]]]
+        producer1 = opts_to_type(unquote(type), module: MyModule, arg: :ok, unknown_option: 1)
+        producers = opts_to_type(unquote(type), producer1: producer1)
+        opts = [producers: producers]
 
-      assert NimbleOptions.validate(opts, schema) ==
-               {:error,
-                %ValidationError{
-                  key: [:unknown_option],
-                  keys_path: [:producers, :producer1],
-                  message: "unknown options [:unknown_option], valid options are: [:module, :arg]"
-                }}
+        assert NimbleOptions.validate(opts, schema) ==
+                 {:error,
+                  %ValidationError{
+                    key: [:unknown_option],
+                    keys_path: [:producers, :producer1],
+                    message:
+                      "unknown options [:unknown_option], valid options are: [:module, :arg]"
+                  }}
+      end
     end
 
-    test "options with default values" do
-      schema = [
-        producers: [
-          type: :keyword_list,
-          keys: [
-            *: [
-              type: :keyword_list,
-              keys: [
-                arg: [default: :ok]
+    for type <- [:keyword_list, :map] do
+      test "options with default values for #{type}" do
+        schema = [
+          producers: [
+            type: unquote(type),
+            keys: [
+              *: [
+                type: unquote(type),
+                keys: [
+                  arg: [default: :ok]
+                ]
               ]
             ]
           ]
         ]
-      ]
 
-      opts = [producers: [producer1: []]]
+        producer1 = opts_to_type(unquote(type), [])
+        producers = opts_to_type(unquote(type), producer1: producer1)
+        opts = [producers: producers]
 
-      assert NimbleOptions.validate(opts, schema) == {:ok, [producers: [producer1: [arg: :ok]]]}
+        {:ok, [producers: validated_producers]} = NimbleOptions.validate(opts, schema)
+        assert :ok == get_in(validated_producers, [:producer1, :arg])
+      end
     end
 
-    test "all required options present" do
-      schema = [
-        producers: [
-          type: :keyword_list,
-          keys: [
-            *: [
-              type: :keyword_list,
-              keys: [
-                module: [required: true],
-                arg: [required: true]
+    for type <- [:keyword_list, :map] do
+      test "all required options present for #{type}" do
+        schema = [
+          producers: [
+            type: unquote(type),
+            keys: [
+              *: [
+                type: unquote(type),
+                keys: [
+                  module: [required: true],
+                  arg: [required: true]
+                ]
               ]
             ]
           ]
         ]
-      ]
 
-      opts = [producers: [default: [module: MyModule, arg: :ok]]]
+        default = opts_to_type(unquote(type), module: MyModule, arg: :ok)
+        producers = opts_to_type(unquote(type), default: default)
+        opts = [producers: producers]
 
-      assert NimbleOptions.validate(opts, schema) == {:ok, opts}
+        assert NimbleOptions.validate(opts, schema) == {:ok, opts}
+      end
     end
 
-    test "required options missing" do
-      schema = [
-        producers: [
-          type: :keyword_list,
-          keys: [
-            *: [
-              type: :keyword_list,
-              keys: [
-                module: [required: true],
-                arg: [required: true]
+    for type <- [:keyword_list, :map] do
+      test "required options missing for #{type}" do
+        schema = [
+          producers: [
+            type: unquote(type),
+            keys: [
+              *: [
+                type: unquote(type),
+                keys: [
+                  module: [required: true],
+                  arg: [required: true]
+                ]
               ]
             ]
           ]
         ]
-      ]
 
-      opts = [producers: [default: [module: MyModule]]]
+        default = opts_to_type(unquote(type), module: MyModule)
+        producers = opts_to_type(unquote(type), default: default)
+        opts = [producers: producers]
 
-      assert NimbleOptions.validate(opts, schema) ==
-               {:error,
-                %ValidationError{
-                  key: :arg,
-                  keys_path: [:producers, :default],
-                  message: "required option :arg not found, received options: [:module]"
-                }}
+        assert NimbleOptions.validate(opts, schema) ==
+                 {:error,
+                  %ValidationError{
+                    key: :arg,
+                    keys_path: [:producers, :default],
+                    message: "required option :arg not found, received options: [:module]"
+                  }}
+      end
     end
 
-    test "nested options types" do
-      schema = [
-        producers: [
-          type: :keyword_list,
-          keys: [
-            *: [
-              type: :keyword_list,
-              keys: [
-                module: [required: true, type: :atom],
-                stages: [type: :pos_integer]
+    for type <- [:keyword_list, :map] do
+      test "nested options types for #{type}" do
+        schema = [
+          producers: [
+            type: unquote(type),
+            keys: [
+              *: [
+                type: unquote(type),
+                keys: [
+                  module: [required: true, type: :atom],
+                  stages: [type: :pos_integer]
+                ]
               ]
             ]
           ]
         ]
-      ]
 
-      opts = [
-        producers: [
-          producer1: [
-            module: MyProducer,
-            stages: :an_atom
-          ]
-        ]
-      ]
+        producer1 = opts_to_type(unquote(type), module: MyProducer, stages: :an_atom)
+        producers = opts_to_type(unquote(type), producer1: producer1)
+        opts = [producers: producers]
 
-      assert NimbleOptions.validate(opts, schema) ==
-               {:error,
-                %ValidationError{
-                  key: :stages,
-                  value: :an_atom,
-                  keys_path: [:producers, :producer1],
-                  message:
-                    "invalid value for :stages option: expected positive integer, got: :an_atom"
-                }}
+        assert NimbleOptions.validate(opts, schema) ==
+                 {:error,
+                  %ValidationError{
+                    key: :stages,
+                    value: :an_atom,
+                    keys_path: [:producers, :producer1],
+                    message: "invalid value for :stages option: expected positive integer, got: :an_atom"
+                  }}
+      end
     end
 
     test "validate empty keys for :non_empty_keyword_list" do
@@ -1404,78 +1599,87 @@ defmodule NimbleOptionsTest do
                 }}
     end
 
-    test "allow empty keys for :keyword_list" do
-      schema = [
-        producers: [
-          type: :keyword_list,
-          keys: [
-            *: [
-              type: :keyword_list,
-              keys: [
-                module: [required: true, type: :atom],
-                stages: [type: :pos_integer]
+    for type <- [:keyword_list, :map] do
+      test "allow empty keys for #{type}" do
+        schema = [
+          producers: [
+            type: unquote(type),
+            keys: [
+              *: [
+                type: unquote(type),
+                keys: [
+                  module: [required: true, type: :atom],
+                  stages: [type: :pos_integer]
+                ]
               ]
             ]
           ]
         ]
-      ]
 
-      opts = [
-        producers: []
-      ]
+        producers = opts_to_type(unquote(type), [])
+        opts = [producers: producers]
 
-      assert NimbleOptions.validate(opts, schema) == {:ok, opts}
+        assert NimbleOptions.validate(opts, schema) == {:ok, opts}
+      end
     end
 
-    test "default value for :keyword_list" do
-      schema = [
-        batchers: [
-          required: false,
-          default: [],
-          type: :keyword_list,
-          keys: [
-            *: [
-              type: :keyword_list,
-              keys: [
-                stages: [type: :pos_integer, default: 1]
+    for type <- [:keyword_list, :map] do
+      test "default value for #{type}" do
+        empty = opts_to_type(unquote(type), [])
+
+        schema = [
+          batchers: [
+            required: false,
+            default: empty,
+            type: unquote(type),
+            keys: [
+              *: [
+                type: unquote(type),
+                keys: [
+                  stages: [type: :pos_integer, default: 1]
+                ]
               ]
             ]
           ]
         ]
-      ]
 
-      opts = []
+        opts = []
 
-      assert NimbleOptions.validate(opts, schema) == {:ok, [batchers: []]}
+        assert NimbleOptions.validate(opts, schema) == {:ok, [batchers: empty]}
+      end
     end
   end
 
   describe "nested options show up in error messages" do
-    test "for options that we validate" do
-      schema = [
-        socket_options: [
-          type: :keyword_list,
-          keys: [
-            certificates: [
-              type: :keyword_list,
-              keys: [
-                path: [type: :string]
+    for type <- [:keyword_list, :map] do
+      test "for options that we validate for #{type}" do
+        schema = [
+          socket_options: [
+            type: unquote(type),
+            keys: [
+              certificates: [
+                type: unquote(type),
+                keys: [
+                  path: [type: :string]
+                ]
               ]
             ]
           ]
         ]
-      ]
 
-      opts = [socket_options: [certificates: [path: :not_a_string]]]
+        certificates = opts_to_type(unquote(type), path: :not_a_string)
+        socket_options = opts_to_type(unquote(type), certificates: certificates)
+        opts = [socket_options: socket_options]
 
-      assert NimbleOptions.validate(opts, schema) ==
-               {:error,
-                %ValidationError{
-                  key: :path,
-                  value: :not_a_string,
-                  keys_path: [:socket_options, :certificates],
-                  message: "invalid value for :path option: expected string, got: :not_a_string"
-                }}
+        assert NimbleOptions.validate(opts, schema) ==
+                 {:error,
+                  %ValidationError{
+                    key: :path,
+                    value: :not_a_string,
+                    keys_path: [:socket_options, :certificates],
+                    message: "invalid value for :path option: expected string, got: :not_a_string"
+                  }}
+      end
     end
   end
 
@@ -1551,6 +1755,10 @@ defmodule NimbleOptionsTest do
           #{NimbleOptions.docs(nested_schema)}
           """
         ],
+        map_with_keys: [
+          type: :map,
+          keys: [key_a: [type: :string, required: true], key_b: [type: {:map, :string, :integer}]]
+        ],
         other_key: [type: {:list, :atom}]
       ]
 
@@ -1561,6 +1769,12 @@ defmodule NimbleOptionsTest do
 
         * `:interval` (`t:pos_integer/0`) - Interval.
 
+      * `:map_with_keys` (`t:map/0`)
+
+        * `:key_a` (`t:String.t/0`) - Required.
+
+        * `:key_b` (map of `t:String.t/0` keys and `t:integer/0` values)
+
       * `:other_key` (list of `t:atom/0`)
 
       """
@@ -1712,7 +1926,9 @@ defmodule NimbleOptionsTest do
         ref: [type: :reference],
         list_of_ints: [type: {:list, :integer}],
         nested_list_of_ints: [type: {:list, {:list, :integer}}],
-        list_of_kws: [type: {:list, {:keyword_list, []}}]
+        list_of_kws: [type: {:list, {:keyword_list, []}}],
+        map: [type: :map],
+        map_of_strings: [type: {:map, :string, :string}]
       ]
 
       assert NimbleOptions.docs(schema) == """
@@ -1738,6 +1954,10 @@ defmodule NimbleOptionsTest do
 
              * `:list_of_kws` (list of `t:keyword/0`)
 
+               * `:map` (`t:map/0`)
+
+               * `:map_of_strings` (map of `t:String.t/0` keys and `t:String.t/0` values)
+
              """
     end
   end
@@ -1906,4 +2126,7 @@ defmodule NimbleOptionsTest do
       ]
     ]
   end
+
+  defp opts_to_type(:keyword_list, opts), do: opts
+  defp opts_to_type(:map, opts), do: Map.new(opts)
 end