Richer domain types with Ecto custom types


Primitive obsession makes our codebases harder to deal with.

Thankfully, Ecto custom types can help us transform primitives (or native types) into domain structures more quickly and at the boundary!

Suppose we have a custom %PhoneNumber{} struct in our codebase with a module that defines parse/1 and to_string/1 functions (to transform from and to strings).

Now, we can define custom Ecto types like this:

defmodule Scout.Ecto.PhoneNumber do
  use Ecto.Type

  alias Scout.PhoneNumber

  def type, do: :string

  def cast(number) when is_binary(number) do
    {:ok, PhoneNumber.parse(number)}

  def cast(%PhoneNumber{} = number), do: {:ok, number}
  def cast(_), do: :error

  def load(number) when is_binary(number) do
    {:ok, PhoneNumber.parse(number)}

  def dump(%PhoneNumber{} = number), do: {:ok, PhoneNumber.to_string(number)}
  def dump(_), do: :error

What does that module do?

  • We use Ecto.Type to define the behavior and get some default functions,
  • We define a cast/1 function to handle transforming user input (think of what happens with Changeset.cast) into the richer data structure (in this case %PhoneNumber{},
  • We define a load/1 function to transform data from the database into the richer data structure, and
  • We define a dump/1 to transform the richer data structure into the representation we’ll insert into the database (string in this case).

We can then update our schemas to use that custom type:

defmodule Scout.Accounts.Contact do
  use Ecto.Schema
  import Ecto.Changeset

  schema "contacts" do
    field :phone_number, Scout.Ecto.PhoneNumber

    timestamps(type: :utc_datetime)

We can even use that custom type with embedded schemas (imagine one backing up a form):

defmodule ScoutWeb.NewAccount do
  use Ecto.Schema

  embedded_schema do
    field :phone_number, Scout.Ecto.PhoneNumber

Without changing anything else, our domain can now deal exclusively with %PhoneNumber{} structs instead of strings.

  • We transform user input into %PhoneNumber{} when we cast values in our changeset, and
  • We transform values from the database into our richer types when the data is pulled from the database.

Want the latest Elixir Streams in your inbox?

    No spam. Unsubscribe any time.