Schemaless changesets validate forms without the database

almirsarajcic

almirsarajcic

2 hours ago

0 comments

Need to validate form data, preview changes, or validate multi-step workflows without hitting the database? Use schemaless changesets with Ecto.Changeset.apply_action/2 to get validation errors and transformed data.

# ✅ Schemaless changeset for contact form validation
defmodule MyApp.ContactForm do
  import Ecto.Changeset

  @types %{
    name: :string,
    email: :string,
    message: :string,
    subscribe: :boolean
  }

  def changeset(params) do
    {%{}, @types}                                        # Empty map + type definition
    |> cast(params, Map.keys(@types))                    # Cast params to types
    |> validate_required([:name, :email, :message])      # Required fields
    |> validate_format(:email, ~r/@/)                    # Email format
    |> validate_length(:message, min: 10, max: 1000)     # Message length
  end

  def validate(params) do
    params
    |> changeset()
    |> apply_action(:insert)  # Returns {:ok, data} or {:error, changeset}
  end
end

Why this works:

  • {%{}, @types} - Creates changeset without Ecto schema (just a map + type spec)
  • apply_action(:insert) - Validates and returns clean data, or errors if invalid
  • No database - Perfect for forms that don’t map to a single table

Using in LiveView forms:

defmodule MyAppWeb.ContactLive do
  use MyAppWeb, :live_view

  def mount(_params, _session, socket) do
    {:ok, assign(socket, form: to_form(ContactForm.changeset(%{})))}
  end

  def handle_event("validate", %{"contact" => params}, socket) do
    changeset =
      params
      |> ContactForm.changeset()
      |> Map.put(:action, :validate)  # Show errors immediately

    {:noreply, assign(socket, form: to_form(changeset))}
  end

  def handle_event("submit", %{"contact" => params}, socket) do
    case ContactForm.validate(params) do
      {:ok, valid_data} ->
        # Send email, save to external API, etc.
        send_contact_email(valid_data)
        {:noreply, put_flash(socket, :info, "Message sent!")}

      {:error, changeset} ->
        {:noreply, assign(socket, form: to_form(changeset))}
    end
  end
end

Multi-step form validation:

defmodule MyApp.RegistrationWizard do
  import Ecto.Changeset

  @step1_types %{name: :string, email: :string}
  @step2_types %{company: :string, role: :string}
  @step3_types %{password: :string, password_confirmation: :string}

  def validate_step(1, params) do
    {%{}, @step1_types}
    |> cast(params, [:name, :email])
    |> validate_required([:name, :email])
    |> validate_format(:email, ~r/@/)
    |> apply_action(:insert)  # Check if step 1 is valid
  end

  def validate_step(2, params) do
    {%{}, @step2_types}
    |> cast(params, [:company, :role])
    |> validate_required([:company, :role])
    |> apply_action(:insert)
  end

  def validate_step(3, params) do
    {%{}, @step3_types}
    |> cast(params, [:password, :password_confirmation])
    |> validate_required([:password, :password_confirmation])
    |> validate_length(:password, min: 12)
    |> validate_confirmation(:password)
    |> apply_action(:insert)
  end

  def complete_registration(all_params) do
    # All steps validated, now save to database
    with {:ok, step1} <- validate_step(1, all_params),
         {:ok, step2} <- validate_step(2, all_params),
         {:ok, step3} <- validate_step(3, all_params) do
      # Combine validated data and insert
      user_params = step1 |> Map.merge(step2) |> Map.merge(step3)
      %User{}
      |> User.changeset(user_params)
      |> Repo.insert()
    end
  end
end

API parameter validation:

defmodule MyApp.API.SearchParams do
  import Ecto.Changeset

  @types %{
    query: :string,
    page: :integer,
    per_page: :integer,
    sort_by: :string,
    order: :string
  }

  def validate(params) do
    {%{}, @types}
    |> cast(params, Map.keys(@types))
    |> validate_required([:query])
    |> validate_number(:page, greater_than: 0)
    |> validate_number(:per_page, greater_than: 0, less_than_or_equal_to: 100)
    |> validate_inclusion(:sort_by, ["relevance", "date", "title"])
    |> validate_inclusion(:order, ["asc", "desc"])
    |> apply_defaults()
    |> apply_action(:insert)
  end

  defp apply_defaults(changeset) do
    changeset
    |> put_default(:page, 1)
    |> put_default(:per_page, 20)
    |> put_default(:sort_by, "relevance")
    |> put_default(:order, "desc")
  end

  defp put_default(changeset, field, value) do
    case get_field(changeset, field) do
      nil -> put_change(changeset, field, value)
      _ -> changeset
    end
  end
end

The power of apply_action:

# Returns {:ok, %{name: "Alice", email: "alice@example.com"}} if valid
ContactForm.validate(%{"name" => "Alice", "email" => "alice@example.com"})

# Returns {:error, %Ecto.Changeset{}} with errors if invalid
ContactForm.validate(%{"name" => "", "email" => "invalid"})

When to use schemaless changesets:

  • ✅ Contact forms, search forms, filters (no database backing)
  • ✅ Multi-step wizards (validate each step independently)
  • ✅ API parameter validation (type coercion + validation)
  • ✅ Form previews (validate before commit)
  • ✅ External API integrations (validate before sending)

Pro tips:

  • Use :insert, :update, or :delete with apply_action - they’re semantically equivalent for schemaless changesets
  • Combine multiple changesets with with for complex validation flows
  • Add custom validators with validate_change/3 just like schema changesets
  • Use Ecto.Changeset.traverse_errors/2 to format errors for APIs

Links:

Comments (0)

Sign in with GitHub to join the discussion