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

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

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.
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:
Let’s look at how developers typically handle this — and how Ecto Types make it better.
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.
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))
49endNow, your schema becomes:
1schema "packages" do
2 field :name, :string
3 field :category, Category
4endThat’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
7You 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.
Using Ecto.Type like this gives you:
For small or static reference data, it’s a perfect fit.
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.