Validated Prisms

See Example Code

The code on this page is ValidatedPrismBook.java - the page includes it directly, so it is compiled and run by the build.

The smart-constructor optic: a Prism whose match says why not, and all the reasons at once.

What You'll Learn

  • Why a validated boundary needs a fallible, accumulating parse and a total build: the "parse, don't validate" asymmetry captured as an optic
  • Constructing a ValidatedPrism with ValidatedPrism.of, and landing on the railway with parsePath
  • How composition splits: andThen short-circuits into structure while sibling fields accumulate every reason
  • Which compositions preserve the total build (ValidatedPrism, Iso, and Prism-with-a-reason) and why Lens cannot
  • Bridging the optic lattice with fromIso, fromPrism, toPrism, and toAffine
  • The two round-trip laws, and why the second forbids a lossy, normalising build

A Prism<S, A> answers one question about a value: does it match this shape, yes or no? Its match returns Optional<A>, present or empty. At a validated boundary, where a raw wire value (a String off the network) must become an always-valid domain value (an EmailAddress), yes/no is too blunt. A rejected value needs to say why, and ideally give every reason at once ("not an email", "too long"), each located to the field it came from. The reverse direction is never in doubt: a domain value you already hold always renders back to a string.

ValidatedPrism<S, A> captures that asymmetry as two directions with different shapes. parse is fallible and accumulating; build is total:

                 parse  (fallible, accumulating)
   wire value  ───────────────────────────────▶  domain value
   String                                          EmailAddress
   (unvalidated)  ◀───────────────────────────────  (always valid)
                 build  (total, always succeeds)

   parse("  NOPE ")          =  Invalid[ "not an email" ]   (every reason at once)
   parse("ada@corp.example") =  Valid(EmailAddress)
   build(addr)               =  "ada@corp.example"          (never fails)

In code:

import org.higherkindedj.optics.validated.ValidatedPrism;

  static final ValidatedPrism<String, EmailAddress> EMAIL =
      ValidatedPrism.of(
          EmailAddress::parse, // String -> Validated<NonEmptyList<FieldError>, EmailAddress>
          EmailAddress::value); // EmailAddress -> String   (total)


    Validated<NonEmptyList<FieldError>, EmailAddress> parsed = EMAIL.parse("  NOPE ");

    // The only way to obtain an EmailAddress is to parse one: that is the point.
    String rendered =
        EMAIL
            .parse("ada@corp.example")
            .map(EMAIL::build) // build always succeeds
            .orElse("");

    ValidationPath<NonEmptyList<FieldError>, EmailAddress> railway =
        EMAIL.parsePath("ada@corp.example");

Composition: nesting short-circuits, siblings accumulate

Prisms combine in two ways, and the two behave differently when a parse fails.

Nesting with andThen goes deeper into a single value, so it short-circuits. If the outer parse fails there is no inner value to look at, so the first reason wins and parsing stops. This is the same choice ValidationPath makes with via.

Sibling fields accumulate. To report every bad field of a record at once, parse each field with its own prism and combine the results with fields() / accumulate() or the Edits builder. Because the fields are independent, every reason is collected, not just the first.

   Nesting: andThen, deeper into one value       =>  short-circuit
     outer.parse ✗ ─────────────────────────▶  stop, the first reason wins
     outer.parse ✓ ──▶ inner.parse ─────────▶  keep going

   Siblings: fields() / accumulate(), one prism per field    =>  accumulate
     name   ✓
     email  ✗  "not an email"      ┐
     age    ✗  "must be positive"  ├──▶  Invalid[ all reasons at once ]
                                   ┘

Only compositions that preserve the total build yield a ValidatedPrism:

Compose withResultNotes
ValidatedPrism<A, B>ValidatedPrism<S, B>parse short-circuits; build composes
Iso<A, B>ValidatedPrism<S, B>parse maps through; build round-trips
Prism<A, B> + a FieldError reasonValidatedPrism<S, B>the reason speaks for the prism's empty case
Lens<A, B>Deliberately absenta lens needs a base to write into, so no total B -> S build exists

Bridging the lattice

  • ValidatedPrism.fromIso(iso): a parse that never fails.
  • ValidatedPrism.fromPrism(prism, reason): lift a plain prism by supplying the reason its Optional.empty cannot express.
  • toPrism() / toAffine(): forget the reasons (the affine's set leaves non-parsing sources unchanged, preserving the affine laws).

Laws

A lawful validated boundary satisfies both round trips, verified with ValidatedPrismLaws from hkj-test:

    ValidatedPrismLaws.assertValidatedPrismLaws(
        ValidatedPrismBook.EMAIL, "ada@corp.example", "not-an-email");
    // parse-build: parse(build(a)) == Valid(a)
    // build-parse: parse(s) == Valid(a)  =>  build(a) == s   (no lossy parse-normalise)

The second law is the subtle one. If build changes the value as it renders (zero-padding a code, trimming whitespace), the round trip no longer holds. Keep all normalising in parse, and let build render the value faithfully.


Key Takeaways

  • parse is fallible and accumulating (Validated<NonEmptyList<FieldError>, A>); build is total: the parse-don't-validate asymmetry as an optic
  • Nesting short-circuits; siblings accumulate via the assembly builders or Edits
  • Only build-preserving compositions exist: ValidatedPrism, Iso, and Prism-with-a-reason; Lens deliberately not
  • Both round-trip laws are published in hkj-test; the section law forbids lossy build-normalisation
  • parsePath lands on the railway (ValidationPath) directly

Hands-On Learning

Practice the boundary in Tutorial 25: ValidatedPrism (3 exercises, ~10 minutes), and see the runnable ValidatedPrismExample.

The bulk forms: parseAll and parseValues

One prism lifts over whole containers. parseAll(List<? extends S>) parses every element and accumulates every failure, each located by its index — a plain positional segment, so a bad second element under a field labelled emails renders as emails.1: not an email address (through a nested spec, customers.1.email: ...). parseValues(Map<K, ? extends S>) parses a map's values the same way, each failure located by its key (attributes.en: ...); keys pass through untouched.

The null doctrine reaches inside both: a null element or map value is a located, accumulating must not be null at its index or key, never an exception. Three edges stay the caller's NullPointerException, by contract: a null list or map itself, and a null map key — a structurally broken map, not a wrong value. The build direction (buildAll, buildValues) is total like build and rejects nulls outright.

(Bracketed index rendering — emails[1] — is deliberately deferred to the future sealed path-segment model; today's paths are flat dotted segments, and the positional segment matches the map-key grammar.)

See Also


Previous: Prism Toolkit Next: Affines