View Source
Funx.Appendable
Usage Rules
Core Concepts
Protocol + Any Fallback Pattern: Use both together for maximum flexibility
- Protocol implementation = custom aggregation logic for your domain types
- Any fallback = flat list aggregation when no custom implementation exists
- Key insight: Protocol provides structured accumulation, fallback provides universal compatibility
Coerce + Append Pattern: Two-step aggregation process
coerce/1
- normalizes input value into aggregatable formappend/2
- combines two values of the same type- Key pattern: coerce first, then append - enables type-safe accumulation
Structured vs Flat Aggregation: Choose accumulation strategy
- Flat aggregation - uses Any fallback, collects values in plain list
- Structured aggregation - uses custom protocol, maintains domain semantics
Decision: When to Use Each Strategy
Use Protocol When:
- You need domain-specific aggregation logic (ValidationError, Metrics, FormErrors)
- Error context matters for debugging or user feedback
- Combining values requires business logic (timestamps, severity levels, nested structures)
- Type safety and structured data are important for downstream processing
Use Fallback When:
- Simple collection is sufficient for your use case
- You're working with heterogeneous data that doesn't need domain structure
- Performance is critical and flat lists meet your needs
- You're prototyping or in early development phases
Quick Patterns
# STEP 1: Use Any fallback for simple aggregation
validate_positive = fn x ->
Either.lift_predicate(x, &(&1 > 0), "Must be positive: #{x}")
end
Either.validate(-3, [validate_positive])
# Left(["Must be positive: -3"]) # Flat list via Any fallback
# STEP 2: Implement protocol for structured aggregation
defimpl Funx.Appendable, for: ValidationError do
def coerce(%ValidationError{errors: e}), do: ValidationError.new(e)
def append(%ValidationError{} = acc, %ValidationError{} = other) do
ValidationError.merge(acc, other)
end
end
# STEP 3: Use structured aggregation with custom protocol
validate_with_structure = fn x ->
Either.lift_predicate(x, &(&1 > 0), "Must be positive: #{x}")
|> Either.map_left(&ValidationError.new/1)
end
Either.validate(-3, [validate_with_structure])
# Left(ValidationError{errors: ["Must be positive: -3"]}) # Structured
# STEP 4: Custom domain aggregation
defimpl Funx.Appendable, for: MyErrorReport do
def coerce(%MyErrorReport{} = report), do: report
def append(%MyErrorReport{errors: e1}, %MyErrorReport{errors: e2}) do
%MyErrorReport{
errors: e1 ++ e2,
timestamp: DateTime.utc_now(),
severity: max_severity(e1, e2)
}
end
end
Key Rules
- IMPLEMENT PROTOCOL when you need structured, domain-specific aggregation
- USE ANY FALLBACK when flat list accumulation is sufficient
- MUST implement both
coerce/1
andappend/2
(no optional defaults) - ENSURE ASSOCIATIVITY -
append(append(a, b), c) = append(a, append(b, c))
- Pattern: Custom protocol for structure, fallback for simplicity
- Integration: Powers
validate/2
,traverse_a/2
, and other accumulating operations
When to Use
- Protocol implementation: When you need structured aggregation with domain semantics
- Any fallback: When simple list collection is sufficient for your use case
- Validation chains: Error accumulation in
Either.validate/2
- Parallel operations: Result collection in
traverse_a/2
- Custom domains: Metrics, logs, reports that need special combination logic
Anti-Patterns
# ❌ Don't forget associativity requirement
defimpl Funx.Appendable, for: BadExample do
def append(a, b), do: %BadExample{value: a.value - b.value} # Not associative!
end
# ❌ Don't mix flat and structured in same pipeline
Either.validate(data, [
fn x -> Either.left("simple error") end, # String
fn x -> Either.left(ValidationError.new("struct")) end # ValidationError - inconsistent!
])
# ❌ Don't implement protocol unnecessarily
defimpl Funx.Appendable, for: SimpleList do
def coerce(list), do: list
def append(a, b), do: a ++ b # Same as Any fallback - unnecessary!
end
# ❌ Don't violate coerce expectations
defimpl Funx.Appendable, for: WrongExample do
def coerce(value), do: transform_completely(value) # Should normalize, not transform!
def append(a, b), do: combine(a, b)
end
Testing
test "Appendable laws hold" do
e1 = ValidationError.new(["error 1"])
e2 = ValidationError.new(["error 2"])
e3 = ValidationError.new(["error 3"])
# Associativity: (a + b) + c = a + (b + c)
left_assoc = Appendable.append(Appendable.append(e1, e2), e3)
right_assoc = Appendable.append(e1, Appendable.append(e2, e3))
assert left_assoc.errors == right_assoc.errors
end
test "coerce normalizes values" do
# Coerce should normalize, not transform
original = ValidationError.new(["test"])
coerced = Appendable.coerce(original)
assert coerced == original # Should be normalized form
end
test "Any fallback works for unknown types" do
# Any implementation provides universal compatibility
result = Appendable.append("hello", "world")
assert result == ["hello", "world"]
result = Appendable.append(["a", "b"], "c")
assert result == ["a", "b", "c"]
end
test "integration with Either.validate" 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 accumulated validation errors")
end
end
Core Functions
Protocol Functions
# Normalize value for aggregation
Appendable.coerce(ValidationError.new(["error"]))
# Result: %ValidationError{errors: ["error"]}
# Combine two values
ve1 = ValidationError.new(["error 1"])
ve2 = ValidationError.new(["error 2"])
Appendable.append(ve1, ve2)
# Result: %ValidationError{errors: ["error 1", "error 2"]}
Any Fallback Functions
# Universal list coercion
Appendable.coerce("single value") # Result: ["single value"]
Appendable.coerce(["already", "list"]) # Result: ["already", "list"]
# Universal list combination
Appendable.append(["a", "b"], ["c", "d"]) # Result: ["a", "b", "c", "d"]
Appendable.append("single", ["list"]) # Result: ["single", "list"]
How the Any Fallback Works
Automatic List Coercion: Values are normalized into lists for universal compatibility
coerce("single value")
→["single value"]
(wraps non-lists)coerce(["already", "list"])
→["already", "list"]
(preserves lists)- Key insight: Every value becomes list-compatible for aggregation
Flat List Combination: Uses ++
operator for simple concatenation
append(["a", "b"], ["c", "d"])
→["a", "b", "c", "d"]
append("single", ["list"])
→["single", "list"]
(coerces then appends)- Performance note: O(n) for each append operation due to list concatenation
Protocol Dispatch Skip: When no custom implementation exists, Any fallback activates
- No struct-specific logic needed
- Universal compatibility across all types
- Simple, predictable behavior for mixed-type scenarios
Integration with Monadic Operations
Either.validate Integration
Note on map_left: While Appendable itself doesn't provide map
, it frequently appears within monadic transformations like map_left
for wrapping structured errors.
# Flat aggregation (Any fallback)
Either.validate(data, [
fn x -> Either.left("error 1") end,
fn x -> Either.left("error 2") end
])
# Result: Left(["error 1", "error 2"])
# Structured aggregation (custom protocol with map_left)
validate_with_structure = fn x ->
Either.lift_predicate(x, &valid?/1, "validation failed")
|> Either.map_left(&ValidationError.new/1) # map_left wraps for Appendable
end
Either.validate(data, [validate_with_structure])
# Result: Left(ValidationError{errors: ["validation failed"]})
traverse_a Integration
# Accumulate results using Appendable
data = [invalid1, invalid2, valid3]
kleisli_validator = fn item ->
if valid?(item) do
Either.right(process(item))
else
Either.left(ValidationError.new(["Invalid: #{item}"]))
end
end
Either.traverse_a(data, kleisli_validator)
# Result: Left(ValidationError{errors: ["Invalid: invalid1", "Invalid: invalid2"]})
Custom Domain Integration
# Custom metrics aggregation
defimpl Funx.Appendable, for: Metrics do
def coerce(%Metrics{} = m), do: m
def append(%Metrics{count: c1, sum: s1}, %Metrics{count: c2, sum: s2}) do
%Metrics{count: c1 + c2, sum: s1 + s2}
end
end
# Use with parallel operations
Either.traverse_a(data, &collect_metrics/1)
# Result: Right(Metrics{count: total_count, sum: total_sum})
Advanced Patterns
Conditional Aggregation
# Choose aggregation strategy based on context
def validate_with_strategy(data, strategy) do
validators = case strategy do
:strict -> strict_validators() |> Enum.map(&wrap_in_validation_error/1)
:lenient -> lenient_validators() # Use Any fallback
end
Either.validate(data, validators)
end
Monoid-like Aggregation
# Empty/identity-like behavior
defimpl Funx.Appendable, for: OptionalResult do
def coerce(%OptionalResult{} = r), do: r
def append(%OptionalResult{present: false}, other), do: other # Identity-like
def append(first, %OptionalResult{present: false}), do: first # Identity-like
def append(first, second), do: combine_results(first, second)
end
Hierarchical Aggregation
# Nested error structures
defimpl Funx.Appendable, for: FormErrors do
def coerce(%FormErrors{} = fe), do: fe
def append(%FormErrors{field_errors: fe1}, %FormErrors{field_errors: fe2}) do
%FormErrors{
field_errors: Map.merge(fe1, fe2, fn _key, v1, v2 ->
Appendable.append(v1, v2) # Recursive aggregation
end)
}
end
end
Performance Considerations
- Appendable operations should be efficient for repeated aggregation
coerce/1
is called on every value - keep it lightweightappend/2
is called repeatedly during accumulation - optimize for performance- Consider lazy evaluation for expensive aggregation operations
- Any fallback uses list concatenation - O(n) for each append operation
Best Practices
- Implement Appendable for types that need structured accumulation
- Keep coerce/1 as a normalization step, not a transformation
- Ensure append/2 is associative for predictable behavior
- Use Any fallback when simple list collection is sufficient
- Test associativity law in your implementations
- Consider performance implications of repeated aggregation
- Document domain-specific aggregation semantics clearly
Design Patterns
Error Accumulation Pattern
Use Appendable to collect validation errors without coupling to specific error types:
def validate_user(user_data) do
validators = [
&validate_email/1,
&validate_password/1,
&validate_age/1
]
Either.validate(user_data, validators) # Uses Appendable automatically
end
Metrics Collection Pattern
Aggregate domain metrics using custom Appendable implementations:
def collect_processing_metrics(items) do
Either.traverse_a(items, &process_with_metrics/1)
# Automatically aggregates metrics using custom Appendable
end
Flexible Aggregation Pattern
Choose aggregation strategy at runtime without changing core logic:
def process_with_aggregation(data, error_type) do
validator = case error_type do
:structured -> &wrap_in_custom_error/1
:simple -> &return_simple_string/1
end
Either.validate(data, [validator]) # Appendable handles both cases
end
Summary
Funx.Appendable
provides flexible, type-safe aggregation for accumulating results across monadic operations:
- Custom protocol - implement for structured, domain-specific aggregation
- Any fallback - universal flat list aggregation for simple cases
- Two-step process - coerce for normalization, append for combination
- Associative requirement - ensures predictable aggregation behavior
- Monadic integration - powers
validate/2
,traverse_a/2
, and other accumulating operations - Performance aware - optimize coerce/append for repeated aggregation scenarios
Canon: Use custom Appendable for structured accumulation, rely on Any fallback for simple collection, ensure associativity in implementations.