Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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.

like image 717
Maciej Małecki Avatar asked Nov 15 '17 19:11

Maciej Małecki


1 Answers

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).

like image 119
Maciej Małecki Avatar answered Nov 15 '22 08:11

Maciej Małecki