“That’s just, like, your opinion, man.” —The Big Lebowski (1998)

A predicate is a function that checks whether a condition holds and returns true or false. Validation builds on this idea by not only checking the condition but also explaining why it failed. A validation applies a predicate to some value and returns either the value itself when the condition is satisfied or a description of the failure when it is not.

Get the code

Ash Validation

Ash implements validation using the changeset pattern.

%Ash.Changeset{
  data: %Superhero{},      # Current record
  attributes: %{},         # Proposed changes
  arguments: %{},          # Additional arguments
  errors: [],              # Error accumulator
  valid?: true             # Refinement flag
}

A changeset is constructed as a product type, holding the value, the proposed changes, and the accumulated errors all at the same time. Validation, however, is a sum type problem. A value is either valid or invalid, never both. The changeset includes the valid? flag to indicate which branch we are in, but Elixir cannot enforce that exclusivity, it cannot prevent illegal states, such as being marked valid while containing errors.

Here is a basic validation:

defmodule MissionControl.Superhero.Validations.MustBeOffDuty do
  use Ash.Resource.Validation
  alias MissionControl.Superhero
  alias Ash.Error.Changes.InvalidAttribute

  @impl true
  def validate(changeset, _opts, _context) do
    superhero = changeset.data

    if Superhero.off_duty?(superhero) do
      :ok
    else
      {:error,
       InvalidAttribute.exception(
         field: :status,
         message: "#{superhero.alias} must be off duty",
         value: superhero.status
       )}
    end
  end
end

We call the off_duty?/1 predicate. If it returns true, the validation returns :ok. If it returns false, we return the reason for failure, in this case, Ash’s InvalidAttribute exception.

Working with changesets makes it straightforward to express state machine rules. With a changeset, we don’t just validate whether the current state is valid, but whether the current state can move to the proposed state. This also lets us implement conditional rules, such as running an expensive validation only when a specific attribute is changing.

update :update do
  require_atomic? false
  primary? true
  accept [:name, :alias, :status, :fights_won, :fights_lost, :health]
  validate AliasIsUnique, where: [changing(:alias)]
end

A validation returns :ok or {:error, reason}.

Ash converts this result into the next changeset:

  • :ok → pass initial changeset
  • {:error, reason} → append error and pass updated changeset

With the changeset threading through accumulated errors, we can add conditional execution:

validate expensive_check(), only_when_valid?: true

The only_when_valid? guard skips the validation if the changeset is invalid (valid? == false). Here we can choose to skip expensive checks when a prior validation has failed. But while we can skip expensive steps, this lack of independence means we cannot run expensive validations in parallel.

Ash provides validations at both the resource and action level.

Resource

Resource-level constraints are defined on attributes:

attribute :health, :integer do
  description "Current health points (0-100)"
  allow_nil? false
  public? true
  constraints min: 0, max: 100
  default 100
end

Here, the health attribute must be present and must fall within the allowed range. All actions in the resource should respect these rules.

Actions

Actions also include validations:

update :dispatch do
  require_atomic? false
  accept []
  validate MustBeOnDuty
  change set_attribute(:status, :dispatched)
end

Here we are validating that to dispatch a superhero, they must already be on duty. Here, Ash will run multiple validations in order.

Ash Change

A change is a function that receives a changeset and returns a changeset. It acts as an escape-hatch, allowing us to modify the changeset in midstream any way we see fit. Ash offers safe helper functions, but does not prevent us from writing unsafe code.

For instance, we use Ash’s set_attribute/2 helper on our status action change set_attribute(:status, :dispatched).

We could just as well write this by hand:

def change(changeset, _opts, _context) do
  %{changeset | attributes: Map.put(changeset.attributes, :status, :dispatched)}
end

Here we are modifying our proposed changes on the success path. But by directly manipulating the attributes map, we bypass the constraint validation that normally happens during handle_params. This means we can set invalid values:

def change(changeset, _opts, _context) do
  # Health has constraints: min: 0, max: 100
  # But this bypasses those constraints entirely:
  %{changeset | attributes: Map.put(changeset.attributes, :health, 999)}
end

Using Ash’s set_attribute/3 helper would go through the proper validation path, but nothing prevents us from using direct map manipulation.

We can also update the error path, such as changing the format:

def change(changeset, _opts, _context) do
  changeset
  |> Map.update!(:errors, fn errs -> [%CustomError{} | errs] end)
end

Or even arbitrarily change the status of our validation logic mid-stream:

def change(changeset, _opts, _context) do
  changeset
  |> Map.put(:valid?, true)
end

Ash runs validations and changes in the same pipeline and in sequence.

For instance, validate and then change:

update :dispatch do
  require_atomic? false
  accept []
  validate MustBeOnDuty
  change set_attribute(:status, :dispatched)
end

Or, change and then validate:

update :dispatch do
  require_atomic? false
  accept []
  change set_attribute(:status, :dispatched)
  validate MustBeOnDuty
end

The power of a change is that it has full access to the changeset and can shape the action’s behavior. The trade-off is that the changeset is now a place where we can make unsafe changes. Ash expects devs to use its helper-functions, but exposes change as an escape hatch.

Next, let’s think about validation in terms of functional programming.

Validation in Functional Programming

In type theory, exclusive choice is modeled with a sum type, and in functional programming this appears as Either with Right(value) for success and Left([reasons]) for failure. The value is the successful state that continues through the pipeline, and it does not include fields like errors, which belong only to the failure branch. With the sum type, mixed states are not possible.

Errors are represented as non-empty lists. We do not want different shapes for failures whether we are running one validation or many, so both single validation failure and multiple validation failures produce a non-empty list.

Either.right(5)
Either.left(["Not an even number"])

Either is biased, a pipeline can move from success to error, but not back.

Monadic bind

We could chain the results of one validation to another.

value
|> validate_even()
|> bind(&validate_less_ten/1)

Here:

  • with 4 we would receive Right(4)
  • with 11 we would receive Left(["Not even"])
  • with 12 we would receive Left(["Not less than 10"]).

Bind takes the result of one Either and feeds it into the next, short-circuiting on the first failure. This works, but because each validation receives the output of the previous one, the checks are dependent. In functional programming we generally prefer to run validations independently.

Monadic Sequence

In the context of Either, sequence takes a list of Either values and returns an Either with Right([values]) or Left(reason). Like bind, a sequence stops at the first error.

on Right we get a list of the successes:

4 → [validate_even, validate_less_ten] → Right([4, 4])

But on Left we get the first error:

11 → [validate_even, validate_less_ten] → Left(["Not even"])

or:

12 → [validate_even, validate_less_ten] → Left(["Not less than 10"])

If we have expensive validations, sequence can be a good choice, where we can run our tests independently and stop at the first opportunity.

But in validation, we usually want to collect multiple errors, not just the first. In functional programming this means switching from the monadic sequence to an applicative sequence.

Applicative Sequence

A monad short-circuits lefts, and an applicative collects.

This means our success is the same:

4 → [validate_even, validate_less_ten] → Right([4, 4])

But the error now collects all information:

11 → [validate_even, validate_less_ten] → Left(["Not even", "Not less than 10"])

Validate

To validate means to run the same value across a series of checks. So rather than returning a list of the values on Right, which by definition will be identical, we can just return the value we were checking.

In Funx it looks like this:

def validate(value, validators) when is_list(validators) do
  traverse_a(validators, fn validator -> validator.(value) end)
  |> map(fn _ -> value end)
end

traverse_a produces the same result as an applicative sequence, which is just a special case of traverse. On success, we modify (map) the result to return the original value.

An Either is evaluated sequentially, so even though the logic is independent, it still runs in order. To run in parallel we traverse in the context of Effect rather than Either.

Run Validations in Parallel

When deleting an assignment we need to confirm certain states:

  • mission formally closed
  • all issued equipment returned
  • civilians confirmed safe
  • any backup units released
  • insurance forms completed

Each of these checks can take from 50 to 250ms.

It would be difficult to recover a deleted record, so we want to run these checks before deleting the record (validation).

We could run them using Ash Validations.

destroy :destroy do
  primary? true
  require_atomic? false
  validate MissionClosed
  validate EquipmentReturned
  validate CiviliansSafe
  validate BackupReleased
  validate InsuranceCompleted
  change ReleaseSuperheroBestEffort
end

But now our clients will have to wait between 250 and 1250ms (Ash runs validations sequentially). We could add the only_when_valid? guard, having Ash skip validations if a previous has failed, but this only improves our speed when an early validation fails. This also means we’d need to consider validation order. Should we check the slowest items first? Or is there some other logical order, such as verifying certain conditions before others?

From a functional programming perspective, we are trying to solve an independence problem using a sequential model. Instead, we should run independent validations concurrently, where our wait time is only as slow as our slowest individual validation response.

Here is a expensive simulated check:

defp slow_check?(_value) do
  delay_ms = Enum.random(1..5) * 50
  :timer.sleep(delay_ms)
  Logger.info("Completed slow check for #{delay_ms}ms")
  Enum.random(1..5) != 1
end

This predicate takes between 50-250ms and randomly fails 1 out of 5 times.

Next we lift it into an Either, which takes a predicate and returns Right(value) or Left([errors])

defp slow_validate(superhero, check_name) do
  Either.lift_predicate(
    check_name,
    &slow_check?/1,
    fn _value -> [format_error(superhero.alias, check_name)] end
  )
end

Either does not model concurrency, so even independent checks will run one after the other.

Instead, we want to lift our validations into the Effect context. Effect expresses asynchronous computations that can fail:

def mission_clearance(superhero) do
  Effect.lift_either(fn ->
    slow_validate(superhero, :mission_clearance)
  end)
end

Now we can run our checks concurrently with Effect.validate/2:

defmodule MissionControl.Assignment.Validations.CheckBeforeDelete do
  use Ash.Resource.Validation
  import Funx.Foldable
  alias Funx.Monad.Effect
  alias MissionControl.Validations

  @impl true
  def validate(changeset, _opts, _context) do

    validators = [
      &Validations.mission_clearance/1,
      &Validations.equipment_return/1,
      &Validations.citizen_safety/1,
      &Validations.backup_coverage/1,
      &Validations.insurance_claims/1
    ]

    assignment = Ash.load!(changeset.data, :superhero)

    Effect.validate(assignment.superhero, validators)
    |> Effect.run()
    |> map_result_to_ash()
  end

  defp map_result_to_ash(value) do
    fold_l(
      value,
      fn _val -> :ok end,
      fn errors -> {
        :error,
        field: :base,
        message: "#{Enum.join(errors, ", ")}"
      } end
    )
  end
end

With this, we run all five independent validations concurrently, and the total wait time is bounded by the slowest check. In the worst case, the client waits 250ms before getting back a success or a list of errors.

We can still use Ash to define the action, and use Funx behind the scenes to compose the complex concurrent validation pipeline:

destroy :destroy do
  primary? true
  require_atomic? false
  validate CheckBeforeDelete
  change ReleaseSuperheroBestEffort
end

If you want to keep tinkering, you might find the livebook examples from my book helpful (see Chapter 9).

Ash Strengths

Ash patterns are a great fit for resource-oriented workflows.

State-Aware Validation

The changeset’s product type structure holds both the value and accumulated errors simultaneously, allowing each validation to see the full context. This sequential threading means later checks can rely on earlier results. For state machines or workflows where validation order matters, this dependency is useful.

Declarative Configuration Over Composition

Rather than composing validation functions as first-class values, Ash declares validations as configuration on actions. This keeps validation logic colocated with the action definition, making an action’s validation rules visible and easy to understand.

The Escape Hatch

The change callback provides unrestricted access to the changeset structure, providing flexibility when the standard validation model doesn’t fit.

Ash Limitations

Ash validation has some constraints.

Product Type, Not Sum Type

Because the changeset is a product type rather than a sum type, it can hold both success and failure states simultaneously. The valid? flag indicates intent, but Elixir cannot enforce consistency. A change callback can set valid?: true while errors still exist, or modify attributes that violate constraints.

Functional validation uses sum types (Either) where the compiler guarantees exclusivity—a value is Right or Left, never both.

Sequential, Not Applicative

Ash threads the changeset through validations sequentially, accumulating state. Each validation sees the errors from previous checks. This prevents the applicative independence that would allow validations to run in parallel or guarantee they cannot interfere with each other.

Configuration, Not Composition

In functional validation, a single validator and a block of validators share the same type signature (value -> Either [Error] value). This uniformity makes them first-class values that can be passed, returned, and composed infinitely.

Ash validations return :ok | {:error, term}, but validation blocks are not values at all—they’re configuration:

update :create_user do
  validate ValidateEmail
  validate ValidateAge
  validate ValidateName
end

You can reuse individual validator modules across actions, but you cannot extract this composition and pass it around as a unit. The validation block has no type signature, no runtime representation, and no way to be treated as a first-class function.

Conclusion

Ash and functional validation follow different models and solve different parts of the problem. Ash manages the action pipeline and state transitions, threading a changeset through each step. Functional validation builds independent, reusable checks that combine without relying on accumulated state. We do not have to choose one or the other. We can use Funx to build complex validation pipelines and run them in an Ash validation or change.

This is part of a series on the Ash Framework book. Previous: A Closer Look at Actions.

Resources

Ash Framework

Learn how to use Ash Framework to build robust, maintainable Elixir applications. This book covers the principles and practices of domain-driven design with Ash's declarative approach.

Advanced Functional Programming with Elixir

Dive deeper into functional programming patterns and advanced Elixir techniques. Learn how to build robust, maintainable applications using functional programming principles.

Funx - Functional Programming for Elixir

A library of functional programming abstractions for Elixir, including monads, monoids, Eq, Ord, and more. Built as an ecosystem where learning is the priority from the start.