defmodule Funx.Errors.ValidationError do @moduledoc """ [![Run in Livebook](https://livebook.dev/badge/v1/black.svg)](https://livebook.dev/run?url=https%3A%2F%2Fgithub.com%2FJKWA%2Ffunx%2Fblob%2Fmain%2Flivebooks%2Ferrors%2Fvalidation_error.livemd) Represents a validation error in the Funx library. A `ValidationError` wraps one or more domain-level validation messages. It is typically used with `Either.Left` to indicate that a value failed validation and should not proceed in a computation. It can also be raised directly, as it implements the `Exception` behaviour. This module provides functions to construct, merge, and convert validation errors, enabling structured, composable error handling across pipelines and validation chains. ## Functions * `new/1` – Creates a `ValidationError` from a single error string or a list of error strings. * `empty/0` – Returns an empty `ValidationError`. * `merge/2` – Combines two `ValidationError` structs into one. * `from_tagged/1` – Converts a tagged error tuple (`{:error, errors}`) into a `ValidationError`. This module also implements the `Exception`, `String.Chars`, and `Funx.Summarizable` protocols, supporting both human-readable output and structured reporting. ### Usage in validation You can validate a value using a list of validator functions. Each validator returns an `Either.Right` if the check passes, or an `Either.Left` with an error message if it fails. If any validation fails, all errors are aggregated and returned in a single `Left`. In contexts where an error must halt execution, `ValidationError` can be raised directly using `raise/1`. ## Examples You can also use a `ValidationError` to hold errors: ```elixir alias Funx.Errors.ValidationError validate_positive = fn x -> Funx.Monad.Either.lift_predicate(x, &(&1 > 0), fn v -> "Value must be positive: " <> to_string(v) end) |> Funx.Monad.Either.map_left(&ValidationError.new/1) end validate_even = fn x -> Funx.Monad.Either.lift_predicate(x, &(rem(&1, 2) == 0), fn v -> "Value must be even: " <> to_string(v) end) |> Funx.Monad.Either.map_left(&ValidationError.new/1) end Funx.Monad.Either.validate(-3, [validate_positive, validate_even]) #=> %Funx.Monad.Either.Left{ # left: %ValidationError{ # errors: ["Value must be positive: -3", "Value must be even: -3"] # } # } ``` """ import Funx.Macros, only: [eq_for: 2, ord_for: 2] defstruct errors: [], __exception__: true eq_for(Funx.Errors.ValidationError, :errors) ord_for(Funx.Errors.ValidationError, :errors) @behaviour Exception @type t :: %__MODULE__{errors: [String.t()]} @doc """ Creates a `ValidationError` from a single string or list of strings. ## Examples iex> Funx.Errors.ValidationError.new("must be positive") %Funx.Errors.ValidationError{errors: ["must be positive"]} iex> Funx.Errors.ValidationError.new(["must be positive", "must be even"]) %Funx.Errors.ValidationError{errors: ["must be positive", "must be even"]} """ @spec new([String.t()]) :: t() def new(errors) when is_list(errors), do: %__MODULE__{errors: errors} @spec new(String.t()) :: t() def new(error), do: %__MODULE__{errors: [error]} @doc """ Returns an empty `ValidationError`. ## Examples iex> Funx.Errors.ValidationError.empty() %Funx.Errors.ValidationError{errors: []} """ @spec empty() :: t() def empty, do: %__MODULE__{errors: []} @doc """ Merges two `ValidationError` structs into one by concatenating their error lists. ## Examples iex> e1 = Funx.Errors.ValidationError.new("must be positive") iex> e2 = Funx.Errors.ValidationError.new("must be even") iex> Funx.Errors.ValidationError.merge(e1, e2) %Funx.Errors.ValidationError{errors: ["must be positive", "must be even"]} """ @spec merge(t(), t()) :: t() def merge(%__MODULE__{errors: e1}, %__MODULE__{errors: e2}), do: %__MODULE__{errors: e1 ++ e2} @doc """ Converts a tagged error tuple into a `ValidationError`. ## Examples iex> Funx.Errors.ValidationError.from_tagged({:error, ["must be positive"]}) %Funx.Errors.ValidationError{errors: ["must be positive"]} """ @spec from_tagged({:error, [String.t()]}) :: t() def from_tagged({:error, errors}) when is_list(errors), do: new(errors) @impl Exception def exception(args) when is_list(args), do: struct(__MODULE__, args) @impl Exception def exception(message) when is_binary(message), do: %__MODULE__{errors: [message]} @impl Exception def message(%__MODULE__{errors: errors}) do Enum.map_join(errors, ", ", &to_string/1) end end defimpl Funx.Appendable, for: Funx.Errors.ValidationError do alias Funx.Errors.ValidationError def coerce(%ValidationError{errors: e}), do: ValidationError.new(e) def append(%ValidationError{} = acc, %ValidationError{} = other) do ValidationError.merge(acc, other) end end defimpl String.Chars, for: Funx.Errors.ValidationError do alias Funx.Errors.ValidationError def to_string(%ValidationError{errors: errors}) do "ValidationError(#{Enum.map_join(errors, ", ", &Kernel.to_string/1)})" end end defimpl Funx.Summarizable, for: Funx.Errors.ValidationError do def summarize(%{errors: value}), do: {:validation_error, Funx.Summarizable.summarize(value)} end