# Ecto Type Patterns Patterns extracted from Ecto's type system source code. ## Contents 1. [`use Ecto.Type` — The Four-Callback Custom Type](#1-use-ectotype--the-four-callback-custom-type) 2. [`embed_as/1` — Controlling Embedded Serialization](#2-embed_as1--controlling-embedded-serialization) 3. [`equal?/2` — Custom Equality for Change Detection](#3-equal2--custom-equality-for-change-detection) 4. [`Ecto.Enum` — Constrained Atom Fields](#4-ectoenum--constrained-atom-fields) 5. [`Ecto.ParameterizedType` — Types with Options](#5-ectoparameterizedtype--types-with-options) 6. [Schemaless Types — `{data, types}` Changesets](#6-schemaless-types--data-types-changesets) --- ## 1. `use Ecto.Type` — The Four-Callback Custom Type **Source:** [lib/ecto/type.ex#L57-L89](https://github.com/elixir-ecto/ecto/blob/fd2ec52b5ae1f775747308f0fd9ffc160515514b/lib/ecto/type.ex#L57) The canonical custom type implements exactly four callbacks. Each has a distinct role in the data flow: - `type/0` — declares the underlying database column type (`:string`, `:map`, `:integer`, etc.) - `cast/1` — converts external or user-supplied values into your Elixir type; called by `Ecto.Changeset.cast/4` - `load/1` — converts a raw database value into your Elixir type; called when reading rows - `dump/1` — converts your Elixir type into a database-compatible value; called when writing The state diagram is: external --[cast]--> internal --[dump]--> database --[load]--> internal ```elixir defmodule EctoURI do use Ecto.Type def type, do: :map def cast(uri) when is_binary(uri), do: {:ok, URI.parse(uri)} def cast(%URI{} = uri), do: {:ok, uri} def cast(_), do: :error def load(data) when is_map(data) do data = for {key, val} <- data, do: {String.to_existing_atom(key), val} {:ok, struct!(URI, data)} end def dump(%URI{} = uri), do: {:ok, Map.from_struct(uri)} def dump(_), do: :error end ``` **Why:** Ecto calls each callback at a different point in the lifecycle. Without `cast/1`, user input passes through as raw strings. Without `load/1`, the value comes out of the DB as a plain map. Without `dump/1`, Ecto cannot serialize the struct for storage. Implementing all four closes every gap in the round-trip. **Anti-pattern:** Relying on the database to store your Elixir struct as-is, or using `:map` with manual pre/post processing scattered across contexts: ```elixir # BAD — manual conversion at every call site instead of centralizing in a type %Post{} = Repo.get(Post, id) uri = post.url |> Map.from_struct() |> then(&URI.parse/1) # repeated everywhere Repo.update(Post.changeset(post, %{url: Map.from_struct(new_uri)})) ``` ### When to Use **Triggers:** - You want to store an Elixir struct (URI, Decimal, custom struct) in a single database column - Cast/load/dump logic would otherwise be duplicated across changesets and contexts - You want Ecto changesets to accept raw user input and produce the correct Elixir type automatically **Example — before:** ```elixir # Manual conversion in every changeset def changeset(post, params) do post |> cast(params, [:url]) |> validate_change(:url, fn :url, val -> case URI.parse(val) do %URI{host: nil} -> [url: "must be a valid URI"] _ -> [] end end) end # Manual conversion when reading def get_post(id) do post = Repo.get!(Post, id) %{post | url: struct!(URI, post.url)} end ``` **Example — after:** ```elixir # schema declaration — one line field :url, EctoURI # changeset just casts; EctoURI.cast/1 validates shape def changeset(post, params), do: cast(post, params, [:url]) # reading returns %URI{} automatically via EctoURI.load/1 def get_post(id), do: Repo.get!(Post, id) ``` ### When NOT to Use **Don't use this when:** - The transformation is trivial (storing a plain string that needs no parsing) - The field contains highly variable or polymorphic data better handled by embedded schemas - You need association semantics (use Ecto associations, not custom types) **Over-application example:** ```elixir # Overkill — a custom type just to upcase strings defmodule UppercaseString do use Ecto.Type def type, do: :string def cast(v) when is_binary(v), do: {:ok, String.upcase(v)} def cast(_), do: :error def load(v), do: {:ok, v} def dump(v), do: {:ok, v} end ``` **Better alternative:** ```elixir # Changeset validation handles this cleanly without a new type module def changeset(record, params) do record |> cast(params, [:code]) |> update_change(:code, &String.upcase/1) end ``` **Why:** Custom types are for encapsulating a serialization contract that must be enforced consistently at every read and write. For one-off transformations or validations, changeset functions are simpler and more obvious to future readers. --- ## 2. `embed_as/1` — Controlling Embedded Serialization **Source:** [lib/ecto/type.ex#L104-L109](https://github.com/elixir-ecto/ecto/blob/fd2ec52b5ae1f775747308f0fd9ffc160515514b/lib/ecto/type.ex#L104) When a custom type is used inside an `embeds_one` or `embeds_many` field, Ecto calls `embed_as/1` to decide whether to pass the value through `dump/1` or treat it as its own serialized form. The callback receives the embed format (`:json` by default) and returns either `:self` or `:dump`. - `:self` — the value is used as-is when exporting embedded data (dump is still called for DB storage) - `:dump` — `dump/1` is always called, even in embedded contexts `use Ecto.Type` provides a default implementation that returns `:self`. Override it when your type must always run `dump/1` to produce its storable form, even when nested inside an embedded schema. ```elixir defmodule EctoURI do use Ecto.Type # Override to ensure dump/1 is called in embedded contexts def embed_as(_format), do: :self end ``` **Why:** When Ecto builds embedded documents for export (e.g., storing a JSON blob), it needs to know whether to trust the in-memory value or to re-serialize it. If your type holds state in an Elixir struct that cannot be stored directly (like `%URI{}`), returning `:self` without a proper `dump/1` would persist the raw struct map rather than your intended shape. Overriding `embed_as/1` makes the contract explicit. **Anti-pattern:** Assuming the default `:self` is correct for a type whose in-memory and storable representations differ, then debugging mysterious embedded schema corruption: ```elixir # BAD — %URI{} stored as a raw struct map because embed_as/1 was never considered embeds_one :metadata, Metadata do field :canonical_url, EctoURI # stored as %{__struct__: "Elixir.URI", host: ..., ...} end ``` ### When to Use **Triggers:** - Your custom type is used inside `embeds_one` or `embeds_many` fields - The Elixir representation and the storable representation differ (struct vs plain map, etc.) - You observe that embedded fields are persisted with unexpected shapes **Example — before:** ```elixir # embed_as/1 not considered; embedded URL stored as raw struct map defmodule EctoURI do use Ecto.Type def type, do: :map def cast(uri) when is_binary(uri), do: {:ok, URI.parse(uri)} def cast(%URI{} = uri), do: {:ok, uri} def cast(_), do: :error def load(data) when is_map(data) do data = for {key, val} <- data, do: {String.to_existing_atom(key), val} {:ok, struct!(URI, data)} end def dump(%URI{} = uri), do: {:ok, Map.from_struct(uri)} def dump(_), do: :error end ``` **Example — after:** ```elixir defmodule EctoURI do use Ecto.Type def type, do: :map def cast(uri) when is_binary(uri), do: {:ok, URI.parse(uri)} def cast(%URI{} = uri), do: {:ok, uri} def cast(_), do: :error def load(data) when is_map(data) do data = for {key, val} <- data, do: {String.to_existing_atom(key), val} {:ok, struct!(URI, data)} end def dump(%URI{} = uri), do: {:ok, Map.from_struct(uri)} def dump(_), do: :error # Explicit: always pass through dump/1 when exporting embedded values def embed_as(_format), do: :self end ``` ### When NOT to Use **Don't use this when:** - Your type is never used in embedded schemas (the callback has no effect) - The in-memory and storable representations are the same (plain maps, scalars) - You intentionally want to skip `dump/1` in embedded contexts (then return `:dump` and document why) **Over-application example:** ```elixir # Overriding embed_as/1 on a type that stores plain integers defmodule PriorityLevel do use Ecto.Type def type, do: :integer def cast(v) when is_integer(v) and v in 1..5, do: {:ok, v} def cast(_), do: :error def load(v), do: {:ok, v} def dump(v), do: {:ok, v} def embed_as(_), do: :self # Pointless — integer is its own storable form end ``` **Better alternative:** ```elixir # Default embed_as/1 from `use Ecto.Type` is sufficient for scalar types defmodule PriorityLevel do use Ecto.Type def type, do: :integer def cast(v) when is_integer(v) and v in 1..5, do: {:ok, v} def cast(_), do: :error def load(v), do: {:ok, v} def dump(v), do: {:ok, v} end ``` **Why:** `embed_as/1` is only meaningful when there is a gap between what Ecto holds in memory and what must be written to the database. For scalar types where the two representations are identical, the default suffices and adding the override is noise. --- ## 3. `equal?/2` — Custom Equality for Change Detection **Source:** [lib/ecto/type.ex](https://github.com/elixir-ecto/ecto/blob/fd2ec52b5ae1f775747308f0fd9ffc160515514b/lib/ecto/type.ex) Ecto calls `equal?/2` to decide whether a field's value has changed before including it in an `UPDATE` statement. The default delegates to `==`. Override when your type's notion of equality is more nuanced than structural term equality. Common cases: `Decimal` values where `Decimal.equal?/2` differs from `==`, sets or maps where insertion order shouldn't matter, floats with tolerance, URIs where trailing slashes are equivalent. ```elixir defmodule EctoDecimal do use Ecto.Type def type, do: :string def equal?(%Decimal{} = a, %Decimal{} = b), do: Decimal.equal?(a, b) def equal?(a, b), do: a == b end ``` **Why:** `Decimal.new("1.0") == Decimal.new("1.00")` is `false` in Elixir because the structs differ structurally. Without a custom `equal?/2`, Ecto would generate a spurious `UPDATE` every time a Decimal field is loaded and re-saved unchanged. The custom implementation delegates to the type's own equality semantics, preventing unnecessary database writes. **Anti-pattern:** Allowing Ecto to generate spurious UPDATEs because `==` disagrees with logical equality: ```elixir # BAD — default equal?/2 uses == # Decimal.new("1.0") != Decimal.new("1.00") structurally, # so every load-and-save cycle marks the field dirty defmodule EctoDecimal do use Ecto.Type def type, do: :string def cast(v), do: {:ok, Decimal.new(v)} def load(v), do: {:ok, Decimal.new(v)} def dump(v), do: {:ok, Decimal.to_string(v)} # Missing equal?/2 — spurious UPDATEs in production end ``` ### When to Use **Triggers:** - Your type wraps a value with non-structural equality (Decimal, Set, custom structs with computed fields) - You see unexpected `UPDATE` queries in your logs when no meaningful data changed - The type's own library provides an equality function (e.g., `Decimal.equal?/2`) **Example — before:** ```elixir # Tags stored as a sorted list — ["a", "b"] and ["b", "a"] treated as different defmodule TagList do use Ecto.Type def type, do: {:array, :string} def cast(tags) when is_list(tags), do: {:ok, Enum.sort(tags)} def cast(_), do: :error def load(v), do: {:ok, v} def dump(v), do: {:ok, v} # Missing equal?/2 — if DB returns unsorted list, it always appears changed end ``` **Example — after:** ```elixir defmodule TagList do use Ecto.Type def type, do: {:array, :string} def cast(tags) when is_list(tags), do: {:ok, Enum.sort(tags)} def cast(_), do: :error def load(v), do: {:ok, v} def dump(v), do: {:ok, v} def equal?(a, b) when is_list(a) and is_list(b) do Enum.sort(a) == Enum.sort(b) end def equal?(a, b), do: a == b end ``` ### When NOT to Use **Don't use this when:** - Structural equality (`==`) already matches your type's logical equality (most scalars, plain maps) - You intentionally want every change in representation to trigger an update (audit fields, version counters) - The equality logic would require database queries or I/O **Over-application example:** ```elixir # Overriding equal?/2 for a plain string type — == is already correct defmodule TrimmedString do use Ecto.Type def type, do: :string def cast(v) when is_binary(v), do: {:ok, String.trim(v)} def cast(_), do: :error def load(v), do: {:ok, v} def dump(v), do: {:ok, v} def equal?(a, b), do: String.trim(a) == String.trim(b) # misleading end ``` **Better alternative:** ```elixir # cast/1 already normalizes; == on the normalized value is correct defmodule TrimmedString do use Ecto.Type def type, do: :string def cast(v) when is_binary(v), do: {:ok, String.trim(v)} def cast(_), do: :error def load(v), do: {:ok, v} def dump(v), do: {:ok, v} # Default equal?/2 is correct — both sides are already trimmed end ``` **Why:** Once `cast/1` normalizes values to a canonical form, `==` on that canonical form is correct. Overriding `equal?/2` to re-normalize creates two sources of truth for what "equal" means and can hide bugs where values escape normalization. --- ## 4. `Ecto.Enum` — Constrained Atom Fields **Source:** [lib/ecto/enum.ex](https://github.com/elixir-ecto/ecto/blob/fd2ec52b5ae1f775747308f0fd9ffc160515514b/lib/ecto/enum.ex) `Ecto.Enum` is Ecto's built-in parameterized type for fields that hold one of a fixed set of values. It stores atoms as strings in the database, validates membership automatically during `cast/4`, and provides a clean schema-level declaration of what values are legal. ```elixir schema "orders" do field :status, Ecto.Enum, values: [:pending, :processing, :shipped, :delivered, :cancelled] end # Changeset validation is automatic: # cast will reject values outside the enum # DB stores as "pending", "processing", etc. # Can also map atoms to custom DB values: field :status, Ecto.Enum, values: [pending: "PENDING", shipped: "SHIPPED"] ``` **Why:** A fixed set of valid values is a domain constraint that belongs at the type level, not scattered across changeset validations. `Ecto.Enum` co-locates the constraint with the field declaration, making it impossible to add a new status without updating the schema. It also avoids the string-vs-atom impedance mismatch: your application code works with atoms, the DB stores strings. **Anti-pattern:** Using `validate_inclusion` with a hardcoded list when the field is a fixed enum. The valid values now live in two places (schema field type and changeset validation), drift over time, and offer no guarantee that the DB stores a normalized form: ```elixir # BAD — validation is disconnected from the field type schema "orders" do field :status, :string end def changeset(order, params) do order |> cast(params, [:status]) |> validate_inclusion(:status, ["pending", "processing", "shipped"]) # Atoms vs strings already a smell; easy to add a value in one place but not the other end ``` ### When to Use **Triggers:** - A field can only hold one of a fixed, small set of named values (status, role, priority, state machine states) - You want `cast/4` to reject invalid values without writing a custom validator - You want the valid values to be introspectable at runtime via `Ecto.Enum.values/2` **Example — before:** ```elixir schema "tickets" do field :priority, :string end @valid_priorities ~w(low medium high critical) def changeset(ticket, params) do ticket |> cast(params, [:priority]) |> validate_required([:priority]) |> validate_inclusion(:priority, @valid_priorities) end ``` **Example — after:** ```elixir schema "tickets" do field :priority, Ecto.Enum, values: [:low, :medium, :high, :critical] end def changeset(ticket, params) do ticket |> cast(params, [:priority]) |> validate_required([:priority]) # validate_inclusion is implicit — cast rejects invalid values end ``` ### When NOT to Use **Don't use this when:** - The set of valid values is dynamic (loaded from the database, configurable at runtime) - You need rich metadata per value (labels, descriptions, ordering weights) — a separate table or config map is better - The field is an open-ended string that happens to have common values (tags, categories that grow) **Over-application example:** ```elixir # Enum for country codes that may expand and need localization field :country, Ecto.Enum, values: [:us, :gb, :de, :fr, :jp] # Adding a new country requires a schema migration AND a code change ``` **Better alternative:** ```elixir # Reference table with a foreign key field :country_code, :string # Validated against a countries table at the application layer ``` **Why:** `Ecto.Enum` hard-codes valid values into the schema module. When the list is stable and small (status machines, role levels), that is exactly right. When the list is user-managed or requires non-code changes to extend, a reference table decouples the constraint from deployments. --- ## 5. `Ecto.ParameterizedType` — Types with Options **Source:** [lib/ecto/parameterized_type.ex](https://github.com/elixir-ecto/ecto/blob/fd2ec52b5ae1f775747308f0fd9ffc160515514b/lib/ecto/parameterized_type.ex) When a custom type needs configuration options set at field definition time (like `Ecto.Enum`'s `values:` option), implement `Ecto.ParameterizedType` instead of `Ecto.Type`. The `init/1` callback receives field options at compile/load time and returns a `params` term that is threaded through every other callback at runtime. The five callbacks: `init/1`, `type/1`, `cast/2`, `load/3`, `dump/3`. ```elixir defmodule MyApp.StatusType do use Ecto.ParameterizedType def init(opts) do validate = Keyword.fetch!(opts, :validate) %{validate: validate} end def type(_params), do: :string def cast(value, %{validate: validator}) do if validator.(value), do: {:ok, value}, else: :error end def load(value, _loader, _params), do: {:ok, value} def dump(value, _dumper, _params), do: {:ok, value} end # Usage: field :status, MyApp.StatusType, validate: &(&1 in ~w(a b c)) ``` **Why:** `Ecto.Type` callbacks receive no configuration — the type module is a singleton with fixed behavior. `Ecto.ParameterizedType` solves the case where the same type module should behave differently per field (different valid values, different validation rules, different storage formats). `Ecto.Enum` itself is implemented as a parameterized type so the same module handles every `values:` list. **Anti-pattern:** Defining a new type module for every variation of behavior that differs only in configuration: ```elixir # BAD — combinatorial explosion of modules defmodule StatusType.V1 do use Ecto.Type @valid ~w(draft published) def cast(v) when v in @valid, do: {:ok, v} def cast(_), do: :error # ... end defmodule StatusType.V2 do use Ecto.Type @valid ~w(open in_progress closed) # identical code, different @valid end ``` ### When to Use **Triggers:** - The same type logic should apply to multiple fields but with different configuration per field - You are building a reusable library type that schema authors customize via options - The configuration is known at schema definition time (compile-time or application startup) **Example — before:** ```elixir # A separate module per enum — doesn't scale defmodule OrderStatus do use Ecto.Type def type, do: :string def cast(v) when v in ~w(pending shipped), do: {:ok, v} def cast(_), do: :error def load(v), do: {:ok, v} def dump(v), do: {:ok, v} end defmodule TicketPriority do use Ecto.Type def type, do: :string def cast(v) when v in ~w(low high), do: {:ok, v} def cast(_), do: :error def load(v), do: {:ok, v} def dump(v), do: {:ok, v} end ``` **Example — after:** ```elixir defmodule ConstrainedString do use Ecto.ParameterizedType def init(opts), do: %{values: Keyword.fetch!(opts, :values)} def type(_params), do: :string def cast(value, %{values: values}) when value in values, do: {:ok, value} def cast(_, _), do: :error def load(value, _, _), do: {:ok, value} def dump(value, _, _), do: {:ok, value} end # In schemas: field :status, ConstrainedString, values: ~w(pending shipped) field :priority, ConstrainedString, values: ~w(low high) ``` ### When NOT to Use **Don't use this when:** - The type has no configuration — use `Ecto.Type`, which has simpler callback signatures - The configuration changes at runtime rather than at schema definition time (use `cast/1` with runtime state instead) - You only need this for one field in one schema (inline the logic in a changeset validator) **Over-application example:** ```elixir # Parameterized type for a type that has no real options defmodule MaybeString do use Ecto.ParameterizedType def init(_opts), do: %{} # no options used def type(_), do: :string def cast(v, _) when is_binary(v), do: {:ok, v} def cast(_, _), do: :error def load(v, _, _), do: {:ok, v} def dump(v, _, _), do: {:ok, v} end ``` **Better alternative:** ```elixir # No configuration needed — plain Ecto.Type is simpler defmodule MaybeString do use Ecto.Type def type, do: :string def cast(v) when is_binary(v), do: {:ok, v} def cast(_), do: :error def load(v), do: {:ok, v} def dump(v), do: {:ok, v} end ``` **Why:** `Ecto.ParameterizedType` adds arity to every callback (`params` argument) and requires an `init/1` that must be implemented. This complexity is justified when you need per-field configuration. Without actual options, the extra arity is noise that obscures intent. --- ## 6. Schemaless Types — `{data, types}` Changesets **Source:** [lib/ecto/changeset.ex](https://github.com/elixir-ecto/ecto/blob/fd2ec52b5ae1f775747308f0fd9ffc160515514b/lib/ecto/changeset.ex) (documentation for `cast/4`) When you need to validate and cast data without defining a full schema module, pass a `{data, types}` tuple as the first argument to `Ecto.Changeset.cast/4`. The `data` map holds the current values (often `%{}`), and the `types` map specifies field names and their Ecto types. The full changeset API works normally — `validate_required`, `validate_number`, `apply_action`, etc. ```elixir def validate_params(params) do types = %{name: :string, age: :integer, role: :string} {%{}, types} |> Ecto.Changeset.cast(params, Map.keys(types)) |> Ecto.Changeset.validate_required([:name]) |> Ecto.Changeset.validate_number(:age, greater_than: 0) |> Ecto.Changeset.apply_action(:insert) end ``` **Why:** Defining a `use Ecto.Schema` module for a one-off params map is heavyweight: it introduces a new module, struct, and migration concern for data that never touches the database. The `{data, types}` tuple gives you Ecto's casting and validation pipeline — type coercion, error accumulation, `apply_action` — for ephemeral, transient, or API-boundary data structures. **Anti-pattern:** Defining a full schema module solely to validate a one-off params map: ```elixir # BAD — a schema with no table, used only for one validation function defmodule SearchParams do use Ecto.Schema import Ecto.Changeset @primary_key false embedded_schema do field :query, :string field :page, :integer field :per_page, :integer end def changeset(params) do %SearchParams{} |> cast(params, [:query, :page, :per_page]) |> validate_required([:query]) end end ``` ### When to Use **Triggers:** - Validating and casting controller params, webhook payloads, or CLI arguments that are never persisted - Building a multi-step form or wizard where intermediate steps don't map to a database row - Writing a context function that accepts a params map and needs to return normalized data or errors **Example — before:** ```elixir # Full schema module for transient data defmodule ReportFilter do use Ecto.Schema import Ecto.Changeset @primary_key false embedded_schema do field :start_date, :date field :end_date, :date field :user_id, :integer end def changeset(params) do %ReportFilter{} |> cast(params, [:start_date, :end_date, :user_id]) |> validate_required([:start_date, :end_date]) end end ``` **Example — after:** ```elixir def parse_report_filter(params) do types = %{start_date: :date, end_date: :date, user_id: :integer} {%{}, types} |> Ecto.Changeset.cast(params, Map.keys(types)) |> Ecto.Changeset.validate_required([:start_date, :end_date]) |> Ecto.Changeset.apply_action(:validate) end ``` ### When NOT to Use **Don't use this when:** - The data structure is reused across many contexts (define a proper schema or embedded schema) - You need associations, `autogenerate`, or `timestamps()` (requires a full schema) - The validated data will eventually be persisted (start with a schema to avoid a rewrite) **Over-application example:** ```elixir # Schemaless changeset for data that has a natural schema home def update_user_profile(user_id, params) do types = %{name: :string, email: :string, bio: :string} {%{}, types} |> Ecto.Changeset.cast(params, Map.keys(types)) |> Ecto.Changeset.validate_required([:name, :email]) |> Ecto.Changeset.apply_action(:update) # Then manually build an update query — loses Ecto.Repo integration end ``` **Better alternative:** ```elixir # User already has a schema — use it def update_user_profile(user_id, params) do Repo.get!(User, user_id) |> User.profile_changeset(params) |> Repo.update() end ``` **Why:** The `{data, types}` approach trades schema infrastructure for simplicity. That trade is worth it for truly transient data (search filters, report parameters, one-time import validation). For persistent data, the schema module is not overhead — it is the source of truth for what the table contains. --- ## Decision Tree - If storing a fixed set of values → `Ecto.Enum` with `values:` - If wrapping an Elixir struct (URI, Decimal, etc.) in a field → `use Ecto.Type` with all four callbacks - If your type needs configuration options at the field level → `use Ecto.ParameterizedType` - If two values of your type may be logically equal without `==` → override `equal?/2` - If your type is used inside `embeds_one`/`embeds_many` and has a non-trivial serialized form → verify `embed_as/1` - If you need to validate params without a schema → schemaless `{data, types}` changeset