How did Dialyzer come up with an empty list success type?

I’m really not sure how Dialyzer has come up with the success typing specs reported below, but it feels completely wrong. (If someone wants to try this, it requires inflex. We will be open sourcing this soon as MIT, so I have no issues posting it here.)

lib/kinetic_lib/remap.ex:53:call
The function call will not succeed.

KineticLib.Remap.atomize_keys(_struct_or_map :: any(), [{atom(), _}, ...])

will never return since it differs in arguments with
positions 2nd from the success typing arguments:

(map(), [])

The specs clearly indicate keyword, and I am able to call the function with the parameters as I expect them, so either there’s a bug with dialyzer (possible, but unlikely), or I’ve specified the specs incorrectly (much more likely), or there’s a bug in other parts of my code (less likely on the surface than incorrect specs, but I haven’t written unit tests for this yet, so…still likely).

The code is below, hidden.

Help?

Thanks.

Remap code
defmodule Remap do
  @moduledoc """
  Remap allows maps or structs to be rekeyed.
  """

  @type key_transforms :: :camel | :pascal | :underscore | nil
  @type atomize_permit :: :safe | list(String.t()) | :unsafe | nil
  @type stringify_permit :: :all | list(atom) | nil | false

  @type atomize_options :: [
          as: key_transforms,
          permit: atomize_permit,
          keep: boolean,
          nested: boolean
        ]

  @type stringify_options :: [
          as: key_transforms,
          permit: stringify_permit,
          keep: boolean,
          nested: boolean
        ]

  @typep recurse_function :: (struct | map, keyword -> struct | map)

  @doc """
  Convert the keys of the struct or map to atoms. By default, atomize_keys/2
  will perform an unsafe conversion. The following options may be used
  to control the conversion:

  - `as`: `:camel` (convert `camel_value` to `CamelValue`), `:pascal` (convert
    `pascal_value` to `pascalValue`), or `:underscore` (convert `CamelValue`
    to `camel_value`). If not specified, there is no conversion.
  - `permit`: `:safe` (only convert strings to atoms that are already
    existing) or a list of string values that are permitted.
  - `keep`: a boolean value indicating whether non-atom keys should be kept.
    If not specified, atom values are not kept (same as `keep: false`).
  - `nested`: a boolean value indicating whether nested maps or structures, and
     nested maps or structures in lists should be considered. If not specified,
     nested maps and structures are processed as well (same as `nested: true`).
  """
  @spec safe_atomize_keys(struct | map, keyword) :: struct | map
  def safe_atomize_keys(struct_or_map, options \\ []) do
    permit =
      options
      |> Keyword.get(:permit)
      |> case do
        [] -> []
        [_ | _] = list -> list
        _ -> :safe
      end

    atomize_keys(struct_or_map, Keyword.put(options, :permit, permit))
  end

  @doc """
  Convert the keys of the struct or map to atoms. By default, atomize_keys/2
  will perform an unsafe conversion. The following options may be used
  to control the conversion:

  - `as`: `:camel` (convert `camel_value` to `CamelValue`), `:pascal` (convert
    `pascal_value` to `pascalValue`), or `:underscore` (convert `CamelValue`
    to `camel_value`). If not specified, there is no conversion.
  - `permit`: `:safe` (only convert strings to atoms that are already
    existing), a list of string values that are permitted. If not specified,
    unsafe atom conversion is performed.
  - `keep`: a boolean value indicating whether non-atom keys should be kept.
    If not specified, atom values are not kept (same as `keep: false`).
  - `nested`: a boolean value indicating whether nested maps or structures, and
     nested maps or structures in lists should be considered. If not specified,
     nested maps and structures are processed as well (same as `nested: true`).
  """
  @spec atomize_keys(struct | map, keyword) :: struct | map
  def atomize_keys(struct_or_map, options \\ [])

  def atomize_keys(%mod{} = struct, options) do
    map =
      struct
      |> Map.from_struct()
      |> do_atomize_keys(options)

    struct(mod, map)
  end

  def atomize_keys(map, options) when is_map(map) do
    map
    |> do_atomize_keys(options)
    |> Map.new()
  end

  @spec stringify_keys(struct | map) :: map
  def stringify_keys(struct_or_map), do: stringify_keys(struct_or_map, [])

  @spec stringify_keys(struct | map, keyword) :: map
  def stringify_keys(%_{} = struct, options) do
    struct
    |> Map.from_struct()
    |> stringify_keys(options)
  end

  def stringify_keys(map, options) when is_map(map) do
    map
    |> do_stringify_keys(options)
    |> Map.new()
  end

  @spec do_atomize_keys(map, keyword) :: Enumerable.t()
  defp do_atomize_keys(map, options) do
    map
    |> transform_keys_as(Keyword.get(options, :as))
    |> transform_keys_to_atom(Keyword.get(options, :permit))
    |> purge_non_atom_keys(Keyword.get(options, :keep, false))
    |> recurse(&atomize_keys/2, options)
  end

  @spec do_stringify_keys(map, keyword) :: Enumerable.t()
  defp do_stringify_keys(map, options) do
    map
    |> transform_keys_as(Keyword.get(options, :as))
    |> transform_keys_to_string(Keyword.get(options, :permit))
    |> purge_non_string_keys(Keyword.get(options, :keep, false))
    |> recurse(&stringify_keys/2, options)
  end

  @spec transform_keys_as(Enumerable.t(), key_transforms) :: Enumerable.t()
  defp transform_keys_as(enumerable, mode) when mode in ~w(camel pascal underscore)a do
    Stream.map(enumerable, &transform_key_as(&1, mode))
  end

  defp transform_keys_as(enumerable, _mode), do: enumerable

  @spec transform_key_as({any, any}, :camel | :pascal | :underscore) :: {any, any}
  defp transform_key_as({k, _v} = item, _) when not is_binary(k) and not is_atom(k), do: item

  defp transform_key_as({k, v}, :camel), do: {Inflex.camelize(k), v}

  defp transform_key_as({k, v}, :pascal), do: {Inflex.camelize(k, :lower), v}

  defp transform_key_as({k, v}, :underscore), do: {Inflex.underscore(k), v}

  @spec transform_keys_to_atom(Enumerable.t(), atomize_permit) :: Enumerable.t(0)
  defp transform_keys_to_atom(enumerable, :safe) do
    Stream.map(enumerable, &transform_key_to_atom(&1, :safe))
  end

  defp transform_keys_to_atom(enumerable, [_ | _] = allowed) do
    Stream.map(enumerable, &transform_key_to_atom(&1, allowed))
  end

  defp transform_keys_to_atom(enumerable, []), do: enumerable

  defp transform_keys_to_atom(enumerable, _) do
    Stream.map(enumerable, &transform_key_to_atom(&1, :unsafe))
  end

  @spec transform_key_to_atom({any, any}, :safe | :unsafe | list(String.t())) :: {any, any}
  defp transform_key_to_atom({k, v}, :safe) when is_binary(k) do
    try do
      {String.to_existing_atom(k), v}
    rescue
      _ -> {k, v}
    end
  end

  defp transform_key_to_atom({k, v}, :unsafe) when is_binary(k) do
    {String.to_atom(k), v}
  end

  defp transform_key_to_atom({k, v}, allowed) when is_binary(k) do
    if k in allowed do
      {String.to_atom(k), v}
    else
      {k, v}
    end
  end

  defp transform_key_to_atom({_k, _v} = entry, _), do: entry

  @spec transform_keys_to_string(Enumerable.t(), stringify_permit) :: Enumerable.t()
  defp transform_keys_to_string(enumerable, []), do: enumerable

  defp transform_keys_to_string(enumerable, false), do: enumerable

  defp transform_keys_to_string(enumerable, [_ | _] = allowed) do
    Stream.map(enumerable, &transform_key_to_string(&1, allowed))
  end

  defp transform_keys_to_string(enumerable, _) do
    Stream.map(enumerable, &transform_key_to_string(&1, :all))
  end

  @spec transform_key_to_string({any, any}, :all | list(atom)) :: {any, any}
  defp transform_key_to_string({k, v}, :all) when is_atom(k) do
    {Atom.to_string(k), v}
  end

  defp transform_key_to_string({k, v}, allowed) when is_atom(k) do
    if k in allowed do
      {Atom.to_string(k), v}
    else
      {k, v}
    end
  end

  defp transform_key_to_string({_k, _v} = entry, _), do: entry

  @spec purge_non_atom_keys(Enumerable.t(), boolean) :: Enumerable.t()
  defp purge_non_atom_keys(enumerable, keep?) do
    if keep? do
      enumerable
    else
      Stream.filter(enumerable, fn {k, _} -> is_atom(k) end)
    end
  end

  @spec purge_non_string_keys(Enumerable.t(), boolean) :: Enumerable.t()
  defp purge_non_string_keys(enumerable, keep?) do
    if keep? do
      enumerable
    else
      Stream.filter(enumerable, fn {k, _} -> is_binary(k) end)
    end
  end

  @spec recurse(Enumerable.t(), recurse_function, atomize_permit | stringify_permit) ::
          Enumerable.t()
  defp recurse(enumerable, function, options) do
    if Keyword.get(options, :nested, true) do
      Stream.map(enumerable, &do_recurse(&1, function, options))
    else
      enumerable
    end
  end

  @spec do_recurse({any, any}, recurse_function, keyword) :: {any, any}
  defp do_recurse({k, %_{} = v}, function, options) do
    {k, function.(v, options)}
  end

  defp do_recurse({k, v}, function, options) when is_map(v) do
    {k, function.(v, options)}
  end

  defp do_recurse({k, v}, function, options) when is_list(v) do
    if Keyword.keyword?(v) do
      {k, function.(Map.new(v), options)}
    else
      {k, Enum.map(v, &process_list_item(&1, function, options))}
    end
  end

  defp do_recurse({_k, _v} = entry, _function, _options), do: entry

  @spec process_list_item(any, recurse_function, keyword) :: any
  defp process_list_item(item, function, options) when is_map(item) do
    function.(item, options)
  end

  defp process_list_item(items, function, options) when is_list(items) do
    if Keyword.keyword?(items) do
      function.(Map.new(items), options)
    else
      Enum.map(items, &process_list_item(&1, function, options))
    end
  end

  defp process_list_item(item, _function, _options), do: item
end

The spec for recurse is incorrect, the last arg is Keyword list. If I change it to:

 @spec recurse(Enumerable.t(), recurse_function, keyword) :: Enumerable.t()

Dialyzer passes.

The way I figured this out was by progressively commenting out functions in the call chain to see when the error would go away, which was on recurse. Since the code works I assumed the problem was the spec and confirmed it went away when I commented the spec.

3 Likes

Thanks!