Search code examples
erlangelixirstatic-analysistypecheckingdialyzer

Specifying a string value in the type definition for the Elixir typespecs


Is it possible to define a type as follows:

defmodule Role do
  use Exnumerator, values: ["admin", "regular", "restricted"]

  @type t :: "admin" | "regular" | "restricted"

  @spec default() :: t
  def default() do
    "regular"
  end
end

to make a better analyze for the code like:

@type valid_attributes :: %{optional(:email) => String.t,
                            optional(:password) => String.t,
                            optional(:role) => Role.t}

@spec changeset(User.t, valid_attributes) :: Ecto.Changeset.t
def changeset(%User{} = user, attrs = %{}) do
  # ...
end

# ...

User.changeset(%User{}, %{role: "superadmin"}) |> Repo.insert()

I know that I can define this type as @type t :: String.t, but then, Dialyzer won't complain about using a different value than possible (possible from the application point of view).

I didn't saw any hints about this use case in the documentation for the Typespecs, but maybe I'm missing something.


Solution

  • It is not possible to use binary values in the described way. However, similar behavior can be achieved using atoms and - in my case - a custom Ecto type:

    defmodule Role do
      @behaviour Ecto.Type
    
      @type t :: :admin | :regular | :restricted
      @valid_binary_values ["admin", "regular", "restricter"]
    
      @spec default() :: t
      def default(), do: :regular
    
      @spec valid_values() :: list(t)
      def valid_values(), do: Enum.map(@valid_values, &String.to_existing_atom/1)
    
      @spec type() :: atom()
      def type(), do: :string
    
      @spec cast(term()) :: {:ok, atom()} | :error
      def cast(value) when is_atom(value), do: {:ok, value}
      def cast(value) when value in @valid_binary_values, do: {:ok, String.to_existing_atom(value)}
      def cast(_value), do: :error
    
      @spec load(String.t) :: {:ok, atom()}
      def load(value), do: {:ok, String.to_existing_atom(value)}
    
      @spec dump(term()) :: {:ok, String.t} | :error
      def dump(value) when is_atom(value), do: {:ok, Atom.to_string(value)}
      def dump(_), do: :error
    end
    

    It allows to use the following code:

    defmodule User do
      use Ecto.Schema
    
      import Ecto.Changeset
    
      @type t :: %User{}
      @type valid_attributes :: %{optional(:email) => String.t,
                                  optional(:password) => String.t,
                                  optional(:role) => Role.t}
    
      @derive {Poison.Encoder, only: [:email, :id, :role]}
      schema "users" do
        field :email, :string
        field :password, :string, virtual: true
        field :password_hash, :string
        field :role, Role, default: Role.default()
    
        timestamps()
      end
    
      @spec changeset(User.t, valid_attributes) :: Ecto.Changeset.t
      def changeset(%User{} = user \\ %User{}, attrs = %{}) do
      # ...
    end
    

    This way, Dialyzer will catch an invalid user's role:

    User.changeset(%User{}, %{role: :superadmin}) |> Repo.insert()
    

    Unfortunately, it forces using atoms in place of strings in the application. It can be problematic if we already have a big code base or if we need plenty of possible values (the limit of atoms in the system and the the fact that they are not garbage collected).