Fun with Ecto Types

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.

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.

    def show(package_id) do
      package_id
      |> find_package()
      |> load_category()
    end
    
    def load_category(%Package{category_id: id} = package) do
      category = Categories.find_by_id(id)
      %{package | category: category}
    end


    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:

    defmodule Category do
      use Ecto.Type
    
      defstruct [:id, :name, :description, :permalink]
    
      @compile {:inline, all: 0}
    
      # Ecto.Type behaviour
      def type, do: :integer
    
      def cast(%__MODULE__{id: id}), do: {:ok, id}
      def cast(id) when is_integer(id), do: {:ok, id}
      def cast(_), do: :error
    
      def load(id) when is_integer(id) do
        case find_by_id(id) do
          nil -> :error
          category -> {:ok, category}
        end
      end
      def load(_), do: :error
    
      def dump(%__MODULE__{id: id}), do: {:ok, id}
      def dump(id) when is_integer(id), do: {:ok, id}
      def dump(_), do: :error
    
      def all do
        [
          %__MODULE__{
            id: 1,
            name: "Libraries",
            permalink: "libraries"
          },
          %__MODULE__{
            id: 2,
            name: "Frameworks",
            permalink: "frameworks"
          },
          %__MODULE__{
            id: 3,
            name: "Tools",
            permalink: "tools"
          }
        ]
      end
    
      def find_by_id(id), do: Enum.find(all(), &(&1.id == id))
      def find_by_name(name), do: Enum.find(all(), &(&1.name == name))
    end

    Now, your schema becomes:

    schema "packages" do
      field :name, :string
      field :category, Category
    end

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

    def show(package_id) do
      package = find_package(package_id)
    
      # category is already a struct!
      IO.inspect(package.category.name)
    end
    

    What about querying?

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

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

    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.

    paper airplane

    Thoughts? Like what you just read?

    Let's keep the conversation going. Share it on social or reach out and tell us your take.

    mimiquate petmimiquate pet