Validated Prisms
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.
- Why a validated boundary needs a fallible, accumulating
parseand a totalbuild: the "parse, don't validate" asymmetry captured as an optic - Constructing a
ValidatedPrismwithValidatedPrism.of, and landing on the railway withparsePath - How composition splits:
andThenshort-circuits into structure while sibling fields accumulate every reason - Which compositions preserve the total build (
ValidatedPrism,Iso, andPrism-with-a-reason) and whyLenscannot - Bridging the optic lattice with
fromIso,fromPrism,toPrism, andtoAffine - 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 with | Result | Notes |
|---|---|---|
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 reason | ValidatedPrism<S, B> | the reason speaks for the prism's empty case |
Lens<A, B> | Deliberately absent | a 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 itsOptional.emptycannot express.toPrism()/toAffine(): forget the reasons (the affine'ssetleaves 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.
parseis fallible and accumulating (Validated<NonEmptyList<FieldError>, A>);buildis 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, andPrism-with-a-reason;Lensdeliberately not - Both round-trip laws are published in
hkj-test; the section law forbids lossy build-normalisation parsePathlands on the railway (ValidationPath) directly
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.)
- Prisms - The yes/no match this type upgrades
- Accumulating Assembly - Sibling-field accumulation for multi-field parses
- Multi-Edit and Sparse Updates - The update-side counterpart
- Record Mapping -
@GenerateMappingderives whole-recordparse/buildfrom these leaves
Previous: Prism Toolkit Next: Affines