elixir

Fun with Ecto Types

Ecto Types are one of Ecto’s most underrated features. Here’s how they can simplify your code, improve design, and eliminate repetitive logic.

Juan Azambuja avatar

Juan Azambuja

5 minutes - November 05, 2025

At Mimiquate, we care deeply about writing expressive and maintainable Elixir code.
We often look for ways to simplify complex patterns without sacrificing clarity — and one of the most elegant tools in the Ecto toolkit is also one of the most underrated: Ecto Types.

If you’ve been using Ecto.Schema and Ecto.Changeset for a while but have never written a custom type, this post will show you why you probably should.

Why Ecto Types deserve more attention

Ecto.Type is a behavior that defines how a value is cast, dumped, and loaded between Elixir and your database. The Ecto Type's docs have a great diagram of how this works in practice:

Ecto types flow

It’s the mechanism behind built-in types like Ecto.Enum or Ecto.UUID, but you can define your own.

That simple ability — to control how data moves between the database and your domain — can lead to more robust designs, especially when you have fixed reference data that rarely changes.

A Classic Example: Static Categories

Recently, while working on Elixir Observer (one of our open-source projects), we added categories to classify packages.
These categories aren’t user-generated; they’re a fixed, known list that rarely changes.

We wanted to:

  • Represent categories clearly in code
  • Store only a numeric ID in the database
  • Automatically load the full struct when fetching data
  • Let’s look at how developers typically handle this — and how Ecto Types make it better.

    The traditional approach

    A common solution looks like this:

    1. Define a Category struct with identifiers.
    2. Store a category_id on each Package.
    3. Manually load the category when fetching a package.

    1def show(package_id) do
    2  package_id
    3  |> find_package()
    4  |> load_package_category()
    5end
    6
    7def load_category(%Package{category_id: id} = package) do
    8  category = Categories.find_by_id(id)
    9  %{package | category: category}
    10end


    You’d also need to mark the category field as virtual on your schema.
    This works — until someone forgets to call load_package_category/1.
    You can wrap it in helper functions like load_package/1, but this pattern often becomes repetitive and error-prone.

    A Better Approach: Custom Ecto Type

    With a custom Ecto type, we can encapsulate all that logic in a single module:

    1defmodule Category do
    2  use Ecto.Type
    3
    4  defstruct [:id, :name, :description, :permalink]
    5
    6  @compile {:inline, all: 0}
    7
    8  # Ecto.Type behaviour
    9  def type, do: :integer
    10
    11  def cast(%__MODULE__{id: id}), do: {:ok, id}
    12  def cast(id) when is_integer(id), do: {:ok, id}
    13  def cast(_), do: :error
    14
    15  def load(id) when is_integer(id) do
    16    case find_by_id(id) do
    17      nil -> :error
    18      category -> {:ok, category}
    19    end
    20  end
    21  def load(_), do: :error
    22
    23  def dump(%__MODULE__{id: id}), do: {:ok, id}
    24  def dump(id) when is_integer(id), do: {:ok, id}
    25  def dump(_), do: :error
    26
    27  def all do
    28    [
    29      %__MODULE__{
    30        id: 1,
    31        name: "Libraries",
    32        permalink: "libraries"
    33      },
    34      %__MODULE__{
    35        id: 2,
    36        name: "Frameworks",
    37        permalink: "frameworks"
    38      },
    39      %__MODULE__{
    40        id: 3,
    41        name: "Tools",
    42        permalink: "tools"
    43      }
    44    ]
    45  end
    46
    47  def find_by_id(id), do: Enum.find(all(), &(&1.id == id))
    48  def find_by_name(name), do: Enum.find(all(), &(&1.name == name))
    49end

    Now, your schema becomes:

    1schema "packages" do
    2  field :name, :string
    3  field :category, Category
    4end

    That’s it.
    Ecto will automatically load the correct struct when fetching from the DB and dump its ID when saving.

    1def show(package_id) do
    2  package = find_package(package_id)
    3
    4  # category is already a struct!
    5  IO.inspect(package.category.name)
    6end
    7

    What about querying?

    You might wonder how this affects queries.
    Good news — it still feels natural:

    1from(
    2  p in Package,
    3  where: p.category == ^Category.find_by_name("Tools")
    4)

    Under the hood, Ecto will call your type’s dump/1 function, translating the struct into its stored integer ID.

    Why This Matters

    Using Ecto.Type like this gives you:

  • Cleaner APIs – schemas express real domain concepts, not implementation details.
  • Less boilerplate – no need to manually load or cast fields.
  • Safer code – impossible to “forget” loading a virtual field.
  • Easier maintenance – all conversion logic lives in one place.
  • For small or static reference data, it’s a perfect fit.

    Final Thoughts

    Ecto Types are one of those Ecto features that quietly solve a lot of pain points once you start using them. You can check the full implementation of Elixir Observers categories here.
    They help bridge the gap between how you think about your data and how it’s stored, leading to clearer and more reliable designs.

    If you’re repeating the same cast or load logic across multiple modules, consider turning it into a custom type.
    Your future self (and your team) will thank you.

    mimiquate petmimiquate pet