View Source Funx.Errors.ValidationError Usage Rules

Core Concepts

Domain Validation: User-facing validation errors with structured messages

  • Wraps one or more validation failure messages
  • Used with Either.Left to represent validation failures
  • Composable and mergeable for comprehensive error collection

Either Integration: Primary use in Either-based validation chains

  • ValidationError as Left value in Either.Left
  • Integrates with Either.validate/2 for comprehensive validation
  • Use map_left to wrap errors in ValidationError struct

Appendable Composition: Combine multiple validation errors

  • merge/2 - combines two ValidationError structs
  • Appendable protocol - enables automatic error accumulation
  • empty/0 - provides identity for composition

Quick Patterns

alias Funx.Errors.ValidationError
import Funx.Monad, only: [map: 2, bind: 2]

# Single validator returning ValidationError
validate_positive = fn x ->
  if x > 0 do
    Either.right(x)
  else
    Either.left(ValidationError.new("Value must be positive: #{x}"))
  end
end

# PREFERRED: Use Either.validate/2 for comprehensive validation
validators = [
  fn x -> Either.lift_predicate(x, &(&1 > 0), "Must be positive")
           |> Either.map_left(&ValidationError.new/1) end,
  fn x -> Either.lift_predicate(x, &(rem(&1, 2) == 0), "Must be even") 
           |> Either.map_left(&ValidationError.new/1) end
]

Either.validate(-3, validators)
# Left(ValidationError{errors: ["Must be positive", "Must be even"]})

# Form validation with comprehensive errors
Either.validate(form_data, [
  &validate_name_field/1,
  &validate_email_field/1, 
  &validate_age_field/1
]) |> Either.map_left(&format_validation_response/1)

Key Rules

  • Always wrap in Either.Left for validation failures - never use bare ValidationError
  • Use list format for errors - ["error1", "error2"] not single strings
  • Prefer Either.validate/2 over manual error accumulation
  • Use map_left to wrap regular error messages in ValidationError struct
  • Implement Exception behavior when validation must halt execution
  • Merge compatible - use merge/2 to combine multiple ValidationError instances

When to Use

  • Form validation with multiple field errors
  • Domain validation that collects all problems
  • API validation responses that need comprehensive error lists
  • Business rule validation with structured error reporting
  • Validation chains where partial success isn't meaningful

Anti-Patterns

# Don't use ValidationError directly without Either
def validate_user(user) do
  ValidationError.new("Invalid user")  # No context!
end

# Don't mix ValidationError with simple strings in Left
Either.left("simple error")  # Then later...
Either.left(ValidationError.new("structured error"))  # Inconsistent!

# Don't forget to accumulate errors
def validate_fields(data) do
  case validate_name(data.name) do
    {:error, name_error} -> Either.left(ValidationError.new(name_error))
    {:ok, _} ->
      case validate_email(data.email) do  # Lost name validation!
        {:error, email_error} -> Either.left(ValidationError.new(email_error))
        {:ok, _} -> Either.right(data)
      end
  end
end

# Don't create ValidationError with single string when you need lists
ValidationError.new("single error")  # Use list format for consistency

Testing

test "validation error accumulation" do
  validators = [
    fn x -> if x > 0, do: Either.right(x), 
            else: Either.left(ValidationError.new(["must be positive"])) end,
    fn x -> if rem(x, 2) == 0, do: Either.right(x), 
            else: Either.left(ValidationError.new(["must be even"])) end
  ]
  
  case Either.validate(-3, validators) do
    %Left{left: %ValidationError{errors: errors}} ->
      assert "must be positive" in errors
      assert "must be even" in errors
      assert length(errors) == 2
    _ -> flunk("Expected validation errors")
  end
end

test "ValidationError composition" do
  ve1 = ValidationError.new(["error 1"])
  ve2 = ValidationError.new(["error 2"]) 
  
  merged = ValidationError.merge(ve1, ve2)
  assert merged.errors == ["error 1", "error 2"]
end

test "Exception behavior" do
  assert_raise ValidationError, "must be positive", fn ->
    raise ValidationError, errors: ["must be positive"]
  end
end

Core Functions

Construction Functions

# Create from list of error messages
ValidationError.new(["must be positive", "must be even"])

# Create from single error message  
ValidationError.new("must be positive")
# Result: %ValidationError{errors: ["must be positive"]}

# Empty validation error (identity for composition)
ValidationError.empty()
# Result: %ValidationError{errors: []}

# Convert from tagged error tuple
ValidationError.from_tagged({:error, ["field errors"]})

Composition Functions

# Merge two ValidationError structs
ve1 = ValidationError.new(["error 1"])
ve2 = ValidationError.new(["error 2"])
ValidationError.merge(ve1, ve2)
# Result: %ValidationError{errors: ["error 1", "error 2"]}

# Appendable protocol (automatic with Either.validate/2)
import Funx.Appendable
append(ve1, ve2)  # Same as merge/2

Exception Functions

# Raise as exception with error list
raise ValidationError, errors: ["critical validation failure"]

# Raise as exception with single message
raise ValidationError, "critical validation failure"  

# Get exception message
Exception.message(%ValidationError{errors: ["a", "b"]})
# Result: "a, b"

Integration with Either

Validation Chains

# Sequential validation (fails on first error)
Either.right(user_input)
|> bind(&parse_user_data/1)
|> bind(&validate_business_rules/1)  # Returns Either with ValidationError
|> bind(&save_to_database/1)

# Comprehensive validation (collects all errors)  
Either.validate(user_data, [
  &validate_name/1,     # Each returns Either Left(ValidationError) or Right
  &validate_email/1,
  &validate_age/1
])

Converting Simple Errors to ValidationError

# Wrap simple error messages
simple_validator = fn data ->
  if valid?(data) do
    Either.right(data)
  else
    Either.left("Invalid data")
  end
end

# Convert to ValidationError format
enhanced_validator = fn data ->
  simple_validator.(data)
  |> Either.map_left(&ValidationError.new/1)
end

# Or use lift_predicate with map_left
validate_positive = fn x ->
  Either.lift_predicate(x, &(&1 > 0), "Must be positive")
  |> Either.map_left(&ValidationError.new/1)
end

Form Validation Pattern

def validate_registration_form(form_data) do
  validators = [
    create_name_validator(form_data),
    create_email_validator(form_data), 
    create_password_validator(form_data)
  ]
  
  Either.validate(form_data, validators)
  |> case do
    %Right{right: validated_data} -> 
      {:ok, validated_data}
    %Left{left: %ValidationError{errors: errors}} ->
      {:error, %{validation_errors: errors}}
  end
end

defp create_name_validator(form_data) do
  fn _data ->
    if String.length(form_data.name) > 0 do
      Either.right(form_data.name)
    else
      Either.left(ValidationError.new(["Name is required"]))
    end
  end
end

Protocol Implementations

String.Chars

to_string(ValidationError.new(["error 1", "error 2"]))
# Result: "ValidationError(error 1, error 2)"

to_string(ValidationError.empty())
# Result: "ValidationError()"

Funx.Eq and Funx.Ord

# Comparison based on errors list
ve1 = ValidationError.new(["a"])
ve2 = ValidationError.new(["b"])

Eq.eq?(ve1, ve1)        # true  
Eq.eq?(ve1, ve2)        # false
Ord.lt?(ve1, ve2)       # true ("a" < "b")

Funx.Appendable

# Automatic composition in Either.validate/2
import Funx.Appendable

ve1 = ValidationError.new(["error 1"])
ve2 = ValidationError.new(["error 2"])

append(ve1, ve2)
# Result: %ValidationError{errors: ["error 1", "error 2"]}

Advanced Patterns

Curried Validation Functions

# Create reusable validators with currying
validate_height = curry_r(&ensure_height/2)
validate_age = curry_r(&ensure_age/2)

# Apply to specific context
patron
|> Either.validate([
  validate_height.(ride),
  validate_age.(ride) 
])

Fallback Validation with or_else

# Try primary validation, fallback to secondary  
def ensure_vip_or_fast_pass(patron, ride) do
  patron
  |> Either.lift_predicate(&Patron.vip?/1, "#{Patron.get_name(patron)} is not a VIP")
  |> Either.map_left(&ValidationError.new/1)
  |> Either.or_else(fn -> ensure_fast_pass(patron, ride) end)
end

Sequential vs Comprehensive Validation

# Sequential: stop on first failure (bind)
def ensure_eligibility(patron, ride) do
  validate_height = curry_r(&ensure_height/2)
  
  patron
  |> ensure_age(ride)
  |> bind(validate_height.(ride))
end

# Comprehensive: collect all failures (validate)
def validate_eligibility(patron, ride) do
  validate_height = curry_r(&ensure_height/2)
  validate_age = curry_r(&ensure_age/2)
  
  patron
  |> Either.validate([
    validate_height.(ride),
    validate_age.(ride)
  ])
end

Error Message Transformation

# Transform detailed errors to user-friendly messages
validate_eligibility = curry_r(fn patron, ride ->
  validate_eligibility(patron, ride)
  |> Either.map_left(fn _ -> 
    "#{Patron.get_name(patron)} is not eligible for this ride"
  end)
end)

# Select first error from comprehensive validation
Either.validate(patron, validators)
|> Either.map_left(fn [first | _] -> first end)

List Validation Patterns

# Fail-fast list validation (traverse)
def ensure_group_eligibility(patrons, ride) do
  eligible_for_ride = curry_r(&ensure_eligibility/2)
  
  Either.traverse(patrons, eligible_for_ride.(ride))
end

# Comprehensive list validation (traverse_a)
def validate_group_eligibility(patrons, ride) do  
  validate_eligibility = curry_r(&validate_eligibility/2)
  
  Either.traverse_a(patrons, validate_eligibility.(ride))
end

Common Patterns

API Validation Response

def validate_api_request(request) do
  validators = [
    &validate_authentication/1,
    &validate_authorization/1,
    &validate_request_format/1,
    &validate_business_rules/1
  ]
  
  case Either.validate(request, validators) do
    %Right{right: valid_request} ->
      {:ok, valid_request}
    %Left{left: %ValidationError{errors: errors}} ->
      {:error, %{
        status: :validation_failed,
        errors: errors,
        timestamp: DateTime.utc_now()
      }}
  end
end

Multi-Step Validation

def validate_user_registration(data) do
  # Step 1: Format validation
  format_result = Either.validate(data, [
    &validate_email_format/1,
    &validate_password_format/1
  ])
  
  # Step 2: Business rules validation (only if format is valid)
  case format_result do
    %Right{right: _} ->
      Either.validate(data, [
        &validate_email_unique/1,
        &validate_password_strength/1
      ])
    error -> error
  end
end

Error Recovery

def process_with_validation(data) do
  case validate_strict_rules(data) do
    %Right{right: valid_data} -> 
      {:ok, valid_data}
    %Left{left: %ValidationError{errors: errors}} ->
      # Try lenient validation on failure
      if recoverable_errors?(errors) do
        validate_lenient_rules(data)
      else
        {:error, errors}
      end
  end
end

Performance Considerations

  • ValidationError creation is lightweight (just list wrapping)
  • Error message concatenation happens lazily in Exception.message/1
  • merge/2 uses simple list concatenation - efficient for typical error counts
  • Appendable protocol enables efficient accumulation in Either.validate/2
  • String formatting only happens when converting to string representation

Best Practices

  • Use ValidationError for user-facing validation only
  • Always wrap in Either.Left, never return bare ValidationError
  • Prefer list format for errors even for single messages
  • Use Either.validate/2 for comprehensive validation
  • Structure error messages consistently across your application
  • Include context in error messages (field names, values, constraints)
  • Test both successful validation and error accumulation paths
  • Consider internationalization when designing error messages

Error Message Guidelines

# Good: Specific, actionable error messages
"Email field is required"
"Password must be at least 8 characters"
"Age must be between 13 and 120"

# Avoid: Vague or technical error messages  
"Invalid input"
"Validation failed"  
"Error in field processing"

# Good: Include context and constraints
ValidationError.new(["Username '#{username}' is already taken"])
ValidationError.new(["Price #{price} must be greater than $0.00"])

# Good: Use Either.lift_predicate for simple validation
def validate_required_field(value, field_name) do
  Either.lift_predicate(value, &present?/1, "#{field_name} is required")
  |> Either.map_left(&ValidationError.new/1)
end