What Your Spec Generates
Your spec gets only the methods its pair can honour, and each one is law-checked.
So far every mapping in this chapter has offered build and parse. Not every pair can keep both promises. The processor reads how each component crosses, and generates only the methods the pair can honour. That set of methods is the spec's tier.
- Predict which methods a spec's Impl carries, from the shape of its pair
- Send a bound request to
parseorpatch, never to an unguardedreverseGetorset
The code on this page is TiersBook.java and its TiersBookTest.java - the page includes them directly, so they are compiled and run by the build.
You:
EmployeeCardDtocarries two ofEmployee's three components. Where is myparse?The processor: Parse into what? The card has no
age. I could invent one, and thenparsewould hand you an employee who never existed.You: So the card is write-only?
The processor: Better than that.
asLens()gives you a lens whosesettakes the card and the employee you already hold, and writes the card's fields onto it. Theagecomes from your employee, so nothing is invented.You: Suppose one of the card's fields went through a leaf, as an email address would.
The processor: Then the write could refuse a value, and a lens's
sethas no way to report that. So you getpatch(employee, card)instead: the same write-back, returningValidated, with every bad field located.You: Why not generate everything, and throw when it cannot work?
The processor: Because then the types would lie, and you would find out in production. Every method I generate is one whose laws hold for your pair, and one
MappingLawscall checks it in your build.
EmployeeCardDto has no age, so its Impl has no parse. Its asLens().set writes the card onto an employee you already hold, and keeps that employee's age. A Lens works like a hand-written withCard(card) copy method, except that it is a value: it composes, and MappingLaws checks it.
record Employee(String name, String department, int age) {}
record EmployeeCardDto(String name, String department) {}
@GenerateMapping
interface EmployeeCardMapping extends MappingSpec<Employee, EmployeeCardDto> {}
Employee employee = new Employee("Ada", "Research", 36);
Lens<Employee, EmployeeCardDto> badge = EmployeeCardMappingImpl.INSTANCE.asLens();
Employee moved = badge.set(new EmployeeCardDto("Ada", "Platform"), employee);
// Employee[name=Ada, department=Platform, age=36]
Which methods your spec gets
The first question is what kind of spec it is:
flowchart TD
accTitle: Which kind of spec
accDescr: A spec extending UpdateSpec gets updateFrom only. Otherwise, a bean wire with getters only gets parse only, and one with writers only gets build only. Every other spec maps both ways, and the grid decides its methods.
S["your spec"] --> U{"extends<br/>UpdateSpec?"}
U -->|yes| UT(["updateFrom only:<br/>a sparse PATCH"])
U -->|no| B{"a bean with getters only,<br/>or writers only?"}
B -->|"getters only"| PO(["parse, no build"])
B -->|"writers only"| BO(["build, no parse"])
B -->|no| G(["a two-way mapping:<br/>see the grid"])
classDef wire fill:#8caaee,stroke:#1e66f5,color:#232634
classDef tier fill:#a6d189,stroke:#40a02b,color:#232634
classDef decision fill:#e5c890,stroke:#df8e1d,color:#232634
class S wire
class U,B decision
class UT,PO,BO,G tier
In words: a spec extending UpdateSpec gets updateFrom alone. A bean wire, a getter and setter class that the next page covers, gets only parse when it has getters alone, and only build when it has writers alone. Every other spec maps both ways.
A two-way mapping's methods turn on two independent questions: does the wire carry every domain component, and is each one a plain copy?
| Every component is a plain copy | Anything else | |
|---|---|---|
| The wire carries every component | lossless: build, parse, asIso(), asValidatedPrism() | build, parse, asValidatedPrism() |
| The wire carries fewer (a projection) | build, asLens() | build, patch(domain, wire): the validated patch |
- A plain copy carries a value across unchanged. A rename alone, or a flattened group, still copies. A leaf does not, and nor does a nested spec, lifted over a container or not. Neither does an
@OptionalBridgecomponent, or a reference property on a bean wire, which can be left unset. - A derived field is not a plain copy either. On the bottom row the processor refuses it, since the write-back could never honour a component that
buildrecomputes. A flattened group on the bottom row is not supported yet. - A sealed pair dispatches to each subtype's own spec. It gets
build,parseandasValidatedPrism(), neverasIso(): Sealed hierarchies.
asValidatedPrism() is the whole mapping as a leaf, so it nests in another spec and lifts over containers. Where a bean or a bridged component lands has the precise rules. The same methods, as a table to search:
| Method | You get it when |
|---|---|
build | the spec maps both ways, or its bean wire has writers only |
parse | the spec maps both ways and the wire carries every component, or its bean wire has getters only |
asIso() | the wire carries every component, and each is a plain copy; never on a sealed pair |
asValidatedPrism() | the Impl has both build and parse |
asLens() | the wire carries fewer components, and each is a plain copy |
patch(domain, wire) | the wire carries fewer components, and some are not plain copies: the validated patch |
asValidatedParse() | the bean wire has getters only: One-directional beans |
asValidatedBuild() | the bean wire has writers only: One-directional beans |
updateFrom(wire) | the spec extends UpdateSpec, over a bean wire: Sparse PATCH |
A bound request goes to parse or patch
A lossless parse is guarded. A null becomes a located error, and a value the domain's constructor refuses becomes an error carrying its message. asIso().reverseGet runs the same direction with neither guard: it builds the record directly, so whatever the constructor accepts goes in, and whatever it throws propagates. Here a request body left out street:
AddressDto bound = new AddressDto(null, "Leeds", "LS1 4AP"); // the body left out "street"
assertThat(AddressMappingImpl.INSTANCE.asIso().reverseGet(bound))
.isEqualTo(new Address(null, "Leeds", "LS1 4AP")); // no error, and no exception
assertThatValidated(AddressMappingImpl.INSTANCE.parse(bound))
.hasFieldErrors("street: must not be null"); // parse locates it
reverseGet is for round trips of values build produced, so give a freshly bound request to parse. A projection has no parse, and its validated patch carries both guards. A projection whose components are all plain copies gets asLens() instead, and its set builds through the same constructor with neither guard. Check such a request yourself before set, or give a component that needs checking a leaf: the projection then takes patch.
Law-checked, in the repo and in your tests
Every tier is compiled and law-checked in the Higher-Kinded-J build itself, against the published hkj-test law harness. Your own specs get the same check with one call from a test, since hkj-test is a test-scope dependency. It works like an EqualsVerifier test, where one call checks a contract the class promises. Unlike EqualsVerifier, it makes no values of its own, so the laws are checked at the samples you pass:
import org.higherkindedj.optics.laws.MappingLaws;
MappingLaws.assertMappingLaws(
CustomerMappingImpl.INSTANCE.asValidatedPrism(),
new CustomerDto("Ada", "ada@example.org"), // parses
new CustomerDto("Bob", "not-an-email")); // must not parse
Give it the values your boundary actually meets. A sample that should parse must parse cleanly, with no null and nothing the constructor refuses. Through a normalising leaf, it must already be in the form build writes back. A @ParameterizedTest drives several spellings through the same call:
@ParameterizedTest
@CsvSource({
"ada@example.org, not-an-email",
"grace@corp.example, grace.corp.example",
"ada+orders@example.org, ''"
})
void customerMappingObeysTheLawsAtEachSpelling(String parses, String fails) {
MappingLaws.assertMappingLaws(
CustomerMappingImpl.INSTANCE.asValidatedPrism(),
new CustomerDto("Ada", parses),
new CustomerDto("Ada", fails));
}
Each tier has its own overload:
| Your Impl has | Pass to assertMappingLaws |
|---|---|
asIso() | asIso(), asValidatedPrism(), a domain value and a wire value |
asValidatedPrism(), and no asIso() | asValidatedPrism(), a wire that parses and one that does not |
asValidatedPrism(), and a derived field as its only extra | asValidatedPrism() and a domain value |
asLens() | asLens(), a domain value and two wire values |
patch | patch and build as method references, a domain value, a wire that parses and one that does not |
asValidatedParse() | asValidatedParse(), a wire that parses and one that does not |
asValidatedBuild() | asValidatedBuild() and a domain value |
updateFrom | updateFrom as a method reference, a domain value, and an all-absent, a valid and an invalid wire |
Testing With hkj-test says what each overload checks, and how to choose its samples.
Most mapping tools generate the same surface for every pair, and let the unlawful corners fail at runtime. Here each cell of the grid names methods whose laws hold as passing tests: in this repository on every build, and in yours with one call. When a record refactor changes what a pair can support, the generated methods change with it, and the law test tells you at build time, not in production.
You can now predict which methods a spec generates, send requests through parse, and law-check the spec in your build. The rest of this page, the validated patch, is for a projection whose components are not all plain copies.
CouponMapping maps a pair whose components match one for one. Its one leaf tidies the code's spelling, and never fails:
record Coupon(String code, int percent) {}
record CouponDto(String code, int percent) {}
@GenerateMapping
interface CouponMapping extends MappingSpec<Coupon, CouponDto> {
default ValidatedPrism<String, String> code() { // never fails: it only tidies the spelling
return ValidatedPrism.of(
raw -> Validated.validNel(raw.strip().toUpperCase(Locale.ROOT)), code -> code);
}
}
Which methods does CouponMappingImpl carry?
build,parse,asIso()andasValidatedPrism()build,parseandasValidatedPrism()build,parseandasIso(), but noasValidatedPrism(), since nothing can fail
Answer and why
Answer and why
2. The grid's column asks whether a component is a plain copy, not whether it can fail. A leaf is not a plain copy, so the pair sits in the top-right cell. This leaf is no isomorphism anyway: a wire " save10 " parses to SAVE10 and builds back as SAVE10, so the round trip changes the wire.
assertThat(CouponMappingImpl.class.getMethods())
.extracting(Method::getName)
.contains("build", "parse", "asValidatedPrism")
.doesNotContain("asIso", "asLens");
var coupons = CouponMappingImpl.INSTANCE;
assertThatValidated(coupons.parse(new CouponDto(" save10 ", 10)).map(coupons::build))
.hasValue(new CouponDto("SAVE10", 10)); // the round trip changed the wire
Where this lives: Which methods your spec gets.
A client sends the employee card with no name, so the controller holds new EmployeeCardDto(null, "Platform"). It writes the card onto new Employee("Ada", "Research", 36) with EmployeeCardMappingImpl.INSTANCE.asLens().set. What comes back?
Invalid(NonEmptyList[name: must not be null])- A
NullPointerException Employee[name=null, department=Platform, age=36]Employee[name=Ada, department=Platform, age=36], sincesetskips anull
Answer and why
Answer and why
3. set builds through Employee's constructor with no guard, as reverseGet does, and that constructor accepts a null. So the null reaches the domain with no error and no exception:
Employee ada = new Employee("Ada", "Research", 36);
EmployeeCardDto card = new EmployeeCardDto(null, "Platform"); // the client sent no name
assertThat(EmployeeCardMappingImpl.INSTANCE.asLens().set(card, ada))
.isEqualTo(new Employee(null, "Platform", 36)); // no error, and no exception
Where this lives: A bound request goes to parse or patch.
Leaf-carrying projections: the validated patch
A projection whose components are not all plain copies has no lawful lens. A leaf may refuse a value, which a lens's set has no way to report, or rewrite it, which breaks the lens laws. So the processor emits the validated patch tier: build stays, and the write-back returns Validated. A bean projection with a reference property lands here even without a leaf, because that property can be left unset (Bean projections):
record Subscriber(String id, EmailAddress email, int age) {}
record SubscriberDetailsDto(String email, int age) {}
@GenerateMapping
interface SubscriberDetailsMapping extends MappingSpec<Subscriber, SubscriberDetailsDto> {
default ValidatedPrism<String, EmailAddress> email() {
return ValidatedPrism.of(
raw ->
raw.contains("@")
? Validated.validNel(new EmailAddress(raw))
: Validated.invalidNel(FieldError.of("not an email address")),
EmailAddress::value);
}
}
patch(domain, wire) validates every projected component and writes it onto the domain. Every bad field is reported at once, under its component name. The components the wire does not carry are read from the domain argument, so they survive by construction:
Subscriber subscriber = new Subscriber("7", new EmailAddress("ada@corp.example"), 36);
var subscriberDetailsMapping = SubscriberDetailsMappingImpl.INSTANCE;
// The projected components validate and write back; the unprojected id survives untouched.
Validated<NonEmptyList<FieldError>, Subscriber> renewed =
subscriberDetailsMapping.patch(
subscriber, new SubscriberDetailsDto("grace@corp.example", 37));
// Valid(Subscriber[id=7, email=EmailAddress[value=grace@corp.example], age=37])
// Dense semantics: every projected field applies - a null is a located error, never absence.
Validated<NonEmptyList<FieldError>, Subscriber> nullEmail =
subscriberDetailsMapping.patch(subscriber, new SubscriberDetailsDto(null, 37));
// Invalid(NonEmptyList[email: must not be null])
patch applies every projected component, and never leaves one unchanged. A null reference read becomes a located FieldError (must not be null). A bridged Optional component, automatic on a bean wire, reads null as empty and writes that. The REST PATCH contract, where a null means keep the current value, is the sparse UpdateSpec tier. This tier is its dense complement, for writing a validated sub-view onto a bigger record.
A projected component resolves everything a two-way mapping resolves. An explicit leaf beats a plain copy, so a ValidatedPrism<X, X> can normalise. A nested spec's failures compose into dotted paths, and containers lift. A nested wire value parses through its own spec, so a null inside it locates too: a zip left null in a nested address reports address.zip: must not be null, instead of throwing.
A patch result is already the 422 leg's shape: return it as it is.
Like every tier, this one is law-checked:
var subscriberDetailsMapping = SubscriberDetailsMappingImpl.INSTANCE;
MappingLaws.assertMappingLaws(
subscriberDetailsMapping::patch,
subscriberDetailsMapping::build,
new Subscriber("7", new EmailAddress("ada@example.org"), 36), // the current value
new SubscriberDetailsDto("grace@example.org", 41), // parses and changes the domain
new SubscriberDetailsDto("not-an-email", 36)); // located failure
The patch laws are projection identity (patch(d, build(d)) == Valid(d)), idempotence, and located validation. build after patch is deliberately not a law, since a normalising leaf rewrites the wire form. The two-way overload does check build after parse, at a parsing sample already in the form build writes back.
- Two questions pick a two-way mapping's methods: does the wire carry every component, and is each one a plain copy
- A bound request goes to
parseorpatch:reverseGetand a lens'ssethave no guard, so they are for values you already trust - Every tier is law-checked: one
MappingLawscall per tier, the same harness the library's own build runs
- Testing With hkj-test: The law harness
MappingLawsbelongs to - Sparse PATCH: The sparse
updateFromtier - Injecting, Testing, and Diagnostics: Registering a tier's surface as a bean
Previous: Check Your Understanding Next: Bean-Shaped Wires