Specifying a string value in the type definition for the Elixir typespecs
Asked Answered
R

1

8

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.

Reger answered 15/11, 2017 at 19:14 Comment(5)
I guess that this is something with the binaries implementation, because works fine with atomsTyrus
Why would you want to use binaries in the enumeration in the first place? This is counter-idiomatic. Use atoms and dialyzer is all yours.Heritable
@mudasobwa because using atoms is not supported by the Ecto out of the box. As far as I understand, I need to handle casting to string manually.Tyrus
Would not <<"admin">> | ... work? Looks like it should, according to the linked typespecs page.Heritable
Unfortunately no. I've decided to use atoms instead (the answer with code snippets below).Tyrus
R
7

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

Reger answered 16/11, 2017 at 15:48 Comment(2)
“plenty of possible values”—lol, millions? It’s a rule of thumb: for inner data we always prefer atoms over binaries.Heritable
Maybe not millions because default limit is around 1 million. That is just an information to consider ;)Tyrus

© 2022 - 2024 — McMap. All rights reserved.