M05C04: Applicative Validation – Independent Checks, Accumulate All Errors¶
Progression Note¶
By the end of Module 5, you will model every domain concept as immutable algebraic data types (products and tagged sums), eliminating whole classes of runtime errors through exhaustive pattern matching, mypy-checked totality, and pure serialization contracts.
| Module | Focus | Key Outcomes |
|---|---|---|
| 4 | Safe Recursion & Error Handling | Stack-safe tree recursion, folds, Result/Option, streaming validation/retries |
| 5 | Advanced Type-Driven Design | ADTs, exhaustive pattern matching, total functions, refined types |
| 6 | Monadic Flows as Composable Pipelines | bind/and_then, Reader/State-like patterns, error-typed flows |
Core question
How do you replace short-circuiting validation that reports only the first error with lawful applicative combinators that run every check independently and return all errors at once — giving perfect diagnostics in one pass?
Every real-world system eventually hits this wall:
“Why does the user have to fix one validation error at a time in a 27-step cycle when every single problem was detectable from the start?”
The naïve pattern (monadic / short-circuiting):
# BEFORE – fail-fast, terrible UX
def validate_user(raw: RawUser) -> Result[User, ErrInfo]:
name = validate_name(raw.name)
if isinstance(name, Err): return name
email = validate_email(raw.email)
if isinstance(email, Err): return email
age = validate_age(raw.age)
if isinstance(age, Err): return age
return Ok(User(name.value, email.value, age.value))
One error → resubmit → next error → resubmit → rage.
The production pattern: use a dedicated Validation applicative that runs all checks independently and accumulates every error monoidally.
# AFTER – one lawful, composable line
validate_user = v_liftA3(
User,
validate_name >> to_validation,
validate_email >> to_validation,
validate_age >> to_validation,
combine=dedup_stable,
)
validated: Validation[User, ErrInfo] = validate_user(raw_user)
# → VFailure(errors=("name too short", "invalid email", "age negative"))
All errors, instantly. User loves you.
Audience: Engineers tired of “fix one field, resubmit, repeat” who want mathematically correct, all-errors reporting.
Outcome
1. Every short-circuit validator replaced with Validation.
2. All validations proven to satisfy full applicative laws.
3. Complete error reports with zero boilerplate.
Tiny Non-Domain Example – User Registration¶
def validate_name(s: str) -> Result[str, str]:
return Ok(s) if len(s) >= 3 else Err("name too short")
def validate_email(s: str) -> Result[str, str]:
return Ok(s) if "@" in s else Err("missing @")
def validate_age(n: int) -> Result[int, str]:
return Ok(n) if n >= 0 else Err("age negative")
validate_user = v_liftA3(
lambda name, email, age: User(name, email, age),
validate_name >> to_validation,
validate_email >> to_validation,
validate_age >> to_validation,
combine=dedup_stable,
)
validated = validate_user(RawUser("a", "no-at", -9))
# VFailure(errors=("name too short", "missing @", "age negative"))
All three errors collected — no short-circuit.
Why Applicative Validation? (Three bullets every engineer should internalise)¶
- Independent execution: Every check runs fully — perfect diagnostics.
- Lawful composition:
pure(f) <*> pure(x) == pure(f(x)),u <*> pure(y) == pure(lambda f: f(y)) <*> u, etc. — refactoring is safe. - Configurable error combination: Errors combine via any semigroup (concat, dedup, priority, etc.).
Validation is applicative only — we deliberately do not provide monadic bind that would re-introduce short-circuiting.
1. Laws & Invariants (machine-checked)¶
| Law | Formal Statement | Enforcement |
|---|---|---|
| Identity | pure(id) <*> v == v |
Hypothesis |
| Composition | pure(compose) <*> u <*> v <*> w == u <*> (v <*> w) |
Hypothesis |
| Homomorphism | pure(f) <*> pure(x) == pure(f(x)) |
Hypothesis |
| Interchange | u <*> pure(y) == pure(lambda f: f(y)) <*> u |
Hypothesis |
| Error Combination | When both sides fail → errors = combine(left.errors, right.errors) | Property tests |
| Non-empty Failure | VFailure.errors never empty |
Constructor invariant + tests |
2. Decision Table – Validation vs Result¶
| Scenario | Want short-circuit? | Want all errors? | Recommended |
|---|---|---|---|
| API / DB call | Yes | No | Result + monadic bind |
| Form / config / chunk validation | No | Yes | Validation + applicative |
| Optional independent checks | No | Yes | Option + applicative |
3. Public API (fp/applicative.py – mypy --strict clean)¶
\"\"\"Backward-compatible name for Module 05 Validation.
The module-05 cores introduce Validation as an applicative; later cores refer to
it as `fp.validation`. This module keeps the earlier import path working.
\"\"\"
from .validation import * # noqa: F403
4. Reference Implementations (continued)¶
4.1 Before vs After – Multi-field Validation¶
# BEFORE – short-circuit hell
def validate_config(cfg: RawConfig) -> Result[Config, ErrInfo]:
port = validate_port(cfg.port)
if isinstance(port, Err): return port
host = validate_host(cfg.host)
if isinstance(host, Err): return host
timeout = validate_timeout(cfg.timeout)
if isinstance(timeout, Err): return timeout
return Ok(Config(port.value, host.value, timeout.value))
# AFTER – all errors, one line
validate_config = v_liftA3(
Config,
validate_port >> to_validation,
validate_host >> to_validation,
validate_timeout >> to_validation,
combine=dedup_stable,
)
4.2 RAG Integration – Validate Chunk Before Embedding¶
def validate_chunk_for_embedding(chunk: Chunk) -> Validation[Chunk, ErrInfo]:
return v_liftA2(
lambda _text_ok, _meta_ok: chunk,
validate_text_length(chunk.text),
validate_metadata_keys(chunk.metadata),
combine=dedup_stable,
)
validated_chunks = v_traverse(raw_chunks, validate_chunk_for_embedding)
match validated_chunks:
case VSuccess(chunks):
return Ok(chunks)
case VFailure(errors):
return Err(make_errinfo(
code="VALIDATION",
msg="pre-embedding validation failed",
meta={"errors": list(errors)},
))
5. Property-Based Proofs (tests/test_applicative_laws.py)¶
from hypothesis import given, strategies as st
from typing import cast
import pytest
from funcpipe_rag.fp.applicative import *
@given(x=st.integers())
def test_identity(x):
v = v_success(x)
assert v_ap(v_success(lambda x: x), v) == v
@given(x=st.integers())
def test_homomorphism(x):
f = lambda n: n + 10
assert v_ap(v_success(f), v_success(x)) == v_success(f(x))
@given(x=st.integers())
def test_interchange(x):
u = v_success(lambda n: n * 3)
assert v_ap(u, v_success(x)) == v_ap(v_success(lambda f: f(x)), u)
@given(x=st.integers())
def test_composition(x):
f = lambda n: n + 1
g = lambda n: n * 2
u = v_success(f)
v = v_success(g)
w = v_success(x)
left = v_ap(v_ap(v_ap(v_success(compose), u), v), w)
right = v_ap(u, v_ap(v, w))
assert left == right
@given(errs1=st.lists(st.text(), min_size=1),
errs2=st.lists(st.text(), min_size=1))
def test_collects_all_errors_concat(errs1, errs2):
# Cast to silence mypy on phantom types (only in tests)
bad_f: Validation[Callable[[int], int], str] = cast(
Validation[Callable[[int], int], str],
v_failure(errs1),
)
bad_x: Validation[int, str] = cast(
Validation[int, str],
v_failure(errs2),
)
result = v_ap(bad_f, bad_x)
assert result.errors == tuple(errs1 + errs2)
@given(errs1=st.lists(st.text(), min_size=1),
errs2=st.lists(st.text(), min_size=1))
def test_dedup_stable(errs1, errs2):
combined = dedup_stable(tuple(errs1), tuple(errs2))
seen = set()
expected = []
for e in errs1 + errs2:
if e not in seen:
seen.add(e)
expected.append(e)
assert combined == tuple(expected)
def test_v_failure_rejects_empty():
with pytest.raises(ValueError):
v_failure([])
6. Big-O & Allocation Guarantees¶
| Operation | Time | Heap | Notes |
|---|---|---|---|
| v_ap | O(#errors) | O(total errors) | Due to tuple concatenation |
| v_liftA2/3 | O(#errors) | O(total errors) | Same |
| v_sequence | O(N + #errors) | O(total errors) | One final tuple |
7. Anti-Patterns & Immediate Fixes¶
| Anti-Pattern | Symptom | Fix |
|---|---|---|
| Short-circuit validation | One error at a time | Validation + v_liftA* |
| Manual error list building | Duplicated code, missed errors | v_ap with custom combine |
| Empty VFailure | Silent success on error | Enforced non-empty invariant |
| Using Result for multi-error | Poor UX | to_validation / from_validation |
| Duplicate error messages | Noisy output | combine=dedup_stable |
8. Pre-Core Quiz¶
- Applicative = functor + what? → pure + ap
- Validation short-circuits? → Never
- Homomorphism → pure(f) <*> pure(x) == pure(f(x))
- Interchange → u <> pure(y) == pure($y) <> u
- Default error combination → tuple concatenation
9. Post-Core Exercise¶
- Refactor one short-circuit validator to
Validation→ prove it returns all errors. - Add
dedup_stableto an existing validation → verify no duplicates. - Implement
v_liftA4..v_liftA8via currying. - Use
v_traverseto validate an entire batch of chunks before embedding.
Next: M05C05 – Monoids & Semigroups (Aggregation, Logs, Metrics).
You now validate independently and collect every error in one pass — no more “fix one, resubmit” cycles. The rest of Module 5 introduces Monoids (for error combination, logs, metrics) and Monads (for dependent sequencing when you really do want short-circuiting).