Validated Prisms
The smart-constructor optic: a Prism whose match says why not, and all the reasons at once
The code on this page is ValidatedPrismBook.java - the page includes it directly, so it is compiled and run by the build.
- 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, wrapping a throwing parser with the section law guarded viaValidatedPrism.canonical, 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 halves,
ValidatedParseandValidatedBuild, for a boundary crossed one way only - 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:
flowchart LR
W(["wire value<br/>String, unvalidated"])
D(["domain value<br/>EmailAddress, always valid"])
W -->|"parse: fallible, accumulating"| D
D -->|"build: total, never fails"| W
classDef tier fill:#a6d189,stroke:#40a02b,color:#232634
class W,D tier
| Call | Result |
|---|---|
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.
flowchart TD
subgraph nest["Nesting with andThen: deeper into one value, so it short-circuits"]
direction TB
O{"outer.parse"}
O -->|"fails"| Stop(["stop, the first reason wins"])
O -->|"succeeds"| Inner(["inner.parse<br/>keep going"])
end
subgraph sib["Siblings with fields() or accumulate(): independent, so they accumulate"]
direction TB
N(["name ✓"]) --> All(["Invalid[ all reasons at once ]"])
E(["email ✗ not an email"]) --> All
A(["age ✗ must be positive"]) --> All
end
classDef tier fill:#a6d189,stroke:#40a02b,color:#232634
classDef decision fill:#e5c890,stroke:#df8e1d,color:#232634
classDef bad fill:#e78284,stroke:#d20f39,color:#232634
class Inner,N tier
class O decision
class Stop,All,E,A bad
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).
One direction at a time
Each direction is also a type of its own. A ValidatedPrism<S, A> is both a ValidatedParse<S, A>, carrying parse, parsePath and the parse bulk forms, and a ValidatedBuild<S, A>, carrying build and the build bulk forms. Code that needs only one direction asks for that half, so every prism still fits, and so does a boundary that can only be crossed one way:
| Type | Carries | Made with |
|---|---|---|
ValidatedPrism<S, A> | both directions, composition, the lattice bridges | ValidatedPrism.of(parse, build), canonical, fromIso, fromPrism |
ValidatedParse<S, A> | parse, parsePath, parseAll, parseValues, parseKeys, parseEntries | ValidatedParse.of(parse) |
ValidatedBuild<S, A> | build, buildAll, buildValues, buildKeys, buildEntries | ValidatedBuild.of(build) |
A half has no round trip, so neither law below applies to it, and andThen, toPrism and toAffine, which need both directions, stay on the prism. All three types are sealed, so a test double is a value built with of, never a mock. parseEntries takes a ValidatedParse for the values and buildEntries a ValidatedBuild, so either side of a map may be one-directional. A one-directional bean mapping exposes its surface as the half it has.
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, and it constrains the pair, not either direction alone: every accepted wire value must rebuild to exactly itself: build(parse(s).get()) == s whenever s parses. build is free to define the canonical spelling (zero-padded dates, lowercase hex); what the law demands is that parse accept exactly the spelling build renders. A normalising parse (trimming whitespace, folding case) accepts an s that build cannot reproduce, which is exactly the lossy round trip the law forbids. Pick the wire's canonical form (the one build renders), accept it alone, and reject every other spelling with a located error.
That discipline need not be hand-written. The natural way to write a codec (wrap a throwing JDK parser, render on the way out) silently violates the section law whenever the parser is more lenient than the renderer (UUID.fromString accepts uppercase; toString renders lowercase). ValidatedPrism.canonical(message, parse, render) builds the guard into the leaf: every accepted source is checked to render back to exactly itself, so the lenient parse is fine. The render defines the canon, and any spelling it cannot reproduce is a located rejection, never a silent normalisation. Any RuntimeException from the parse or the render inside the guard is the same located rejection, never an exception on wire input. A parse that answers null is rejected too, before the render and without an exception, so a shape check in front of a throwing parser spares its stack trace. The canonical form is whatever render says it is, so a wire whose canon differs from the JDK's is served lawfully:
// An uppercase-UUID wire (SQL Server): the canonical form is THEIRS, lawfully
static final ValidatedPrism<String, UUID> UPPER_UUID =
ValidatedPrism.canonical(
"not an uppercase UUID",
UUID::fromString, // throwing parse, lenient is fine
uuid -> uuid.toString().toUpperCase(Locale.ROOT)); // render defines the canon
An overload takes a pre-built FieldError in place of the message, the same reason type fromPrism takes.
Two obligations stay yours. The parse must accept what the render produces: a mismatched pair (render dd/MM/uuuu, parse MM/dd/uuuu) breaks the parse-build law loudly, as rejections. The subtler trap is a non-injective render, which the per-value guard cannot catch: with a two-digit-year date format, build renders 1926-07-28 as 26/07/28 (a perfectly parseable spelling, of 2026-07-28). The guard, which only ever sees one value at a time, cannot object, and the parse-build law breaks silently. Check a custom canon with the laws:
ValidatedPrismLaws.assertValidatedPrismLaws(
ValidatedPrismBook.UPPER_UUID,
"123E4567-E89B-12D3-A456-426614174000", // the canonical spelling parses
"123e4567-e89b-12d3-a456-426614174000"); // the one render cannot reproduce is rejected
The stock vocabulary in StandardCodecs is built on this same factory: lawful codecs for the standard families (identifiers, dates, enums, money), each accepting exactly the canonical form it renders.
The bulk forms
One prism lifts over whole containers, accumulating every failure and locating each by whatever identifies an element in that container:
| Form | Parses | Each failure locates by |
|---|---|---|
parseAll(List<? extends S>) | every element | its index - emails.1: not an email address, or customers.1.email through a nested spec |
parseAll(Set<? extends S>) | every element | the element's own rendering - emails.nope; a set has no index |
parseAll(S[], IntFunction<A[]>) | every element | its index, as a list. The array constructor supplies the result, since a generic array cannot be created otherwise |
parseValues(Map<K, ? extends S>) | the values; keys pass through | its key - attributes.en: ... |
parseKeys(Map<? extends S, V>) | the keys; values pass through | the source key, naming what the caller sent |
parseEntries(Map, ValidatedParse) | both sides; the receiver parses the keys | the source key, so an entry wrong on both sides reports both reasons there |
The null doctrine reaches inside all of them: a null element or map value is a located, accumulating must not be null, never an exception, while a null container or map key stays the caller's error. A null set element is the unlocated must not contain a null element, a set holding at most one. Every build direction (buildAll, buildValues, buildKeys, buildEntries) is total like build and rejects nulls outright.
Mapping is not injective, so a container can collapse: a prism mapping both "1" and "01" to one domain value leaves a smaller set, and the source spellings cannot be recovered. A set drops the duplicate silently - the domain values that remain are equal to the ones dropped, so the set still holds every distinct value it was given. Two map keys parsing to one domain key are a located duplicates an earlier key instead: there the dropped entry takes its own value with it, and that value need not be equal to anything. Either way the prism doing it already breaks the section law, which is what stops a lawful mapping collapsing at all.
(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.)
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 canonical(message, parse, render)guards the section law per value: the render defines the canonical form and every spelling it cannot reproduce is rejected; that the parse accepts the renderings, injectively, stays your obligation (check withValidatedPrismLaws)- One prism lifts over containers: The bulk forms accumulate every element failure, located by index or key
parsePathlands on the railway (ValidationPath) directly- Each direction is a type of its own:
ValidatedParseandValidatedBuildserve a boundary crossed one way, and every prism is both
Practise the boundary in Tutorial 25: ValidatedPrism (3 exercises), and see the runnable ValidatedPrismExample.
- 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