Defining schemas

Schema

defmodule Person do
  use TypedEctoSchema

  typed_schema "people" do
    field(:name, :string, enforce: true, null: false)
    field(:age, :integer) :: non_neg_integer() | nil
    belongs_to(:company, Company)
    timestamps()
  end
end

Generates the schema, @enforce_keys [:name] and @type t().

Embedded schema

defmodule Address do
  use TypedEctoSchema

  typed_embedded_schema do
    field(:street, :string)
    field(:city, :string, null: false)
  end
end

typed_embedded_schema replaces embedded_schema; everything else works the same.

Type inference

Fields

field(:name, :string)
# String.t() | nil

field(:count, :integer)
# integer() | nil

field(:tags, {:array, :string})
# list(String.t()) | nil

field(:role, Ecto.Enum, values: [:admin, :user])
# (:admin | :user) | nil

field(:price, Money)
# Money.t() | nil — custom types assume a t/0

Associations and embeds

has_one(:profile, Profile)
# Ecto.Schema.has_one(Profile.t()) | nil

has_many(:posts, Post)
# Ecto.Schema.has_many(Post.t())

belongs_to(:company, Company)
# company: Ecto.Schema.belongs_to(Company.t()) | nil
# company_id: integer() | nil

embeds_one(:address, Address)
# Address.t() | nil

embeds_many(:entries, Entry)
# list(Entry.t())

"Many" fields are always lists — never | nil.

Field options

:null — control | nil

field(:name, :string, null: false)
# name: String.t()

field(:phone, :string)
# phone: String.t() | nil (default)

Never applies to has_many/embeds_many (always lists); has_one/belongs_to associations always get | nil.

:enforce — add to @enforce_keys

field(:name, :string, enforce: true)
# @enforce_keys [:name]

:doc — document the field

field(:name, :string, doc: "The person's full name")

Rendered through the moduledoc marker and the generated @typedoc (see below).

:: — override the typespec

field(:age, :integer) :: non_neg_integer() | nil
# age: non_neg_integer() | nil (verbatim)

The override always wins over inference and is used exactly as written — add | nil yourself if wanted.

Schema-level options

Defaults for all fields

typed_schema "people", null: false, enforce: true do
  # String.t(), enforced
  field(:name, :string)
  # opt back out per field
  field(:age, :integer, null: true)
  # defaults are not enforced
  field(:bio, :string, default: "")
end

Other options

typed_schema "people", opaque: true do
  # @opaque t()
end

typed_schema "people", additional_types: true do
  # named types for Ecto.Enum fields (experimental)
end

Generated fields

@primary_key

@primary_key {:id, :binary_id,
              autogenerate: true, null: false}
# id: binary()   (no | nil)

:null and :enforce work here too — they are stripped before Ecto sees them.

timestamps/1

timestamps()             # NaiveDateTime.t() | nil
timestamps(null: false)  # NaiveDateTime.t()
timestamps(type: :utc_datetime)  # DateTime.t() | nil

Nullable by default: a struct that was not inserted yet has nil timestamps.

belongs_to/3 foreign key

belongs_to(:company, Company, null: false)
# company: Ecto.Schema.belongs_to(Company.t()) | nil
# company_id: integer()

:null applies to the generated foreign key; :enforce enforces the association field.

Documenting fields

The moduledoc marker

defmodule Person do
  @moduledoc """
  A person.

  ## Fields

  <!-- typed_ecto_schema: fields -->
  """

  use TypedEctoSchema

  typed_schema "people" do
    field(:name, :string, doc: "The person's full name")
    field(:age, :integer)
  end
end

The marker is replaced at compile time with a list of every field, its typespec and its :doc:

- `id` (`integer() | nil`)
- `name`: The person's full name (`String.t()`)
- `age` (`integer() | nil`)

The marker is the only trigger — without it, :doc options don't touch the @moduledoc.

Controlling the generated @typedoc

# Default: t/0 gets a @typedoc with the
# fields list, for every schema
typed_schema "people" do
  ...
end
# Your own @typedoc is kept; the marker
# is interpolated in it too
@typedoc """
A person. Prefer building via new/1.

<!-- typed_ecto_schema: fields -->
"""
typed_schema "people" do
  ...
end
# Suppress it entirely
@typedoc false
typed_schema "people" do
  ...
end

Field docs show up in t Person.t() in IEx and on hover in editors.

Experimental features

Named types for Ecto.Enum

typed_schema "people", additional_types: true do
  field(:role, Ecto.Enum, values: [:admin, :user])
end
# @type role() :: :admin | :user
# usable as Person.role()

Or enable it globally (compile-time config; the schema-level option still wins in both directions):

# config/config.exs
config :typed_ecto_schema, additional_types: true

For {:array, Ecto.Enum} the named type is the element union — write list(Person.roles()) for the list.

PolymorphicEmbed integration

# config/config.exs (compile-time, off by default)
config :typed_ecto_schema, polymorphic_embed: true
polymorphic_embeds_one(:channel,
  types: [sms: SMS, email: Email],
  on_replace: :update
)
# channel: (SMS.t() | Email.t()) | nil

polymorphic_embeds_many(:channels,
  types: [sms: SMS, email: Email],
  on_replace: :delete
)
# channels: list(SMS.t() | Email.t())

::, :null, :enforce and :doc work as on field/3. With additional_types: true these fields get named types too (element union for _many). polymorphic_embed stays out of this library's deps — add it to yours.