The Emission Tiers: Truthful Types
The generated surface only ever offers what the field correspondences can lawfully support; nothing is fabricated.
Most mapping tools generate the same surface for every pair and let the unlawful corners fail at runtime. @GenerateMapping does the opposite: it reads the field correspondences and emits only the operations they can honour. A lossless pair earns an Iso; a lossy projection (a wire carrying fewer components than the domain) earns a Lens write-back but no parse; a validating projection earns a fallible patch. The types tell the truth, and the truth is law-checked.
- Reading the tier table: which spec shapes emit
asIso(),asLens(),patch,asValidatedPrism(), orupdateFrom - Why a lossless mapping's
parseis still guarded, and whenreverseGetis safe - The validated
patchtier for projections that validate or normalise - Law-checking your own specs with one
MappingLawscall per tier
The code on this page is RecordMappingBook.java - the page includes it directly, so it is compiled and run by the build.
The field correspondences select what the Impl can lawfully offer. As a decision flow:
flowchart TD
S["Your spec interface"] --> U{"extends UpdateSpec<br/>(instead of MappingSpec)?"}
U -->|yes| UT(["updateFrom() only:<br/>a sparse PATCH fold"])
U -->|no| W{"wire has fewer components?<br/>(derived fields don't count)"}
W -->|yes| B{"bean-shaped wire with<br/>any reference property?"}
B -->|yes| BX(["rejected: the bean projection<br/>flavour is not supported yet"])
B -->|no| F{"any fallible correspondence<br/>on a projected component?<br/>(leaf, nested spec, container)"}
F -->|no| LT(["build + asLens():<br/>lawful write-back, no parse"])
F -->|yes| PT(["build + validated patch():<br/>a write-back that can fail"])
W -->|no| D{"any fallible leaf, nested spec,<br/>derived field, or guarded<br/>bean property read?"}
D -->|no| IT(["build + guarded parse<br/>+ lawful asIso()"])
D -->|yes| VT(["build + accumulating parse,<br/>no asIso()"])
IT --> VP(["asValidatedPrism():<br/>the whole mapping as a leaf,<br/>so it nests and lifts"])
VT --> VP
classDef wire fill:#8caaee,stroke:#1e66f5,color:#232634
classDef tier fill:#a6d189,stroke:#40a02b,color:#232634
classDef decision fill:#e5c890,stroke:#df8e1d,color:#232634
classDef error fill:#e78284,stroke:#d20f39,color:#232634
class S wire
class UT,LT,PT,IT,VT,VP tier
class U,W,B,F,D decision
class BX error
(The bean-read leg of that last decision: on a bean wire an unset reference property is an ordinary state, so its guarded reads count as fallible and a lossless-looking bean mapping still lands on the accumulating branch, withholding asIso(); see Beans and Sparse PATCH. The rejected bean projection is #702; an all-primitive bean projection, whose reads can never be null, takes the asLens() branch. And a projection that also declares a derived field is rejected outright, which is why derived fields do not count towards the wire tally.)
And as the reference table:
| Spec shape | Generated surface |
|---|---|
| All components identity-matched (lossless) | build, guarded parse, asIso() |
| Any fallible leaf, nested spec or derived field | build, accumulating parse, no asIso |
| Wire record with fewer components, all identity (lossy projection) | build + asLens() whose set writes the projected components back, no parse (the dropped components cannot be reconstructed) |
| Wire record with fewer components and any fallible correspondence | build + a validated patch(domain, wire) write-back, no asLens and no parse, below |
| Every parse-capable mapping | asValidatedPrism(): the mapping as a leaf, so it nests and lifts |
A spec extending UpdateSpec (opt-in, bean wire) | only updateFrom(Wire): a sparse PATCH fold, Beans and Sparse PATCH |
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);
// department written back, age kept: a lawful lens, not a fake inverse
"Guarded" because even a lossless record parse can fail on a hostile binding (a null reference component, or a null element inside an identity container, is a located invalid); the parse-iso coherence law is therefore stated only for wires whose reference components are non-null. And asIso().reverseGet is a second, unguarded wire-to-domain direction: it exists for lawful in-memory round trips, so never feed a freshly bound wire to reverseGet; locating its nulls is parse's job.
Law-checked, in the repo and in your tests
"Lawfully offer" is verified, not promised: every emission tier above (lossless iso, projection lens, fallible leaf, nested spec, List/Optional/Map lifting, sealed dispatch, derived fields) is compiled and law-checked in the Higher-Kinded-J build itself, against the published hkj-test law harness.
Every mapping tool promises correctness; this one states laws and runs them. The tier table is not documentation of intent: each row names properties that hold as passing tests (round trip, projection identity, idempotence, coherence between surfaces). They run in this repository on every build, and the one call below runs them in yours. When a record refactor changes what the pair can lawfully support, the generated surface changes with it and the law test tells you at build time, not in production. We know of no other Java mapping generator that law-checks its own output; it is the difference between a mapper you trust and a mapper you audit.
Your own specs get the same guarantee with one call from a test (hkj-test is a test-scope dependency):
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
The overloads follow the tiers:
- Lossless mapping: pass
asIso()plusasValidatedPrism()to check the iso laws, both round trips, and the coherence between the two surfaces. - Projection: pass
asLens()with a domain value and two wire values. - Validated patch (leaf-carrying projection): pass the
patchandbuildmethod references, a domain value, and a parsing and a non-parsing wire value (below). - Fallible tier: pass
asValidatedPrism()with a parsing and a non-parsing wire value. - Derived-field (total-parse) mapping:
buildrecomputes whatparseignores, so only the non-derived components round-trip. The domain-sample overloadassertMappingLaws(prism, domainValue)asserts exactly that and nothing stronger. - Sparse-update (
UpdateSpec) mapping: pass theupdateFrommethod reference, a domain value, and an all-absent, a valid and an invalid wire to check the identity, idempotence and validation laws (Beans and Sparse PATCH).
A spec with a derived field and a fallible leaf is better served by the fallible overload, given a parseable wire value whose derived components match what build would produce (this keeps the overload's rejection check on the non-parsing wire). Reserve the domain-sample overload for total-parse mappings, where no well-formed wire value can fail.
The annotation sits on your spec interface, never on the mapped types, so third-party records, sealed hierarchies, and bean-shaped DTOs from compiled libraries map without being annotatable: interface VendorOrderMapping extends MappingSpec<com.vendor.OrderRecord, OrderDto> {} works today. Bean-shaped wire types (getter/setter DTOs) are covered too; see Beans and Sparse PATCH.
Leaf-carrying projections: the validated patch
A projection that also validates or normalises a field (a leaf on a projected component) has no lawful total lens: the write-back can fail. Instead of refusing to generate, the mapping emits the validated patch tier: the total build stays, and the write-back returns Validated:
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) writes every projected component onto the domain, validating each one: every bad field is reported at once, located under its component name, and the unprojected components are read from the domain argument, so they survive untouched by construction:
Subscriber subscriber = new Subscriber("7", new EmailAddress("ada@corp.example"), 36);
// The projected components validate and write back; the unprojected id survives untouched.
Validated<NonEmptyList<FieldError>, Subscriber> renewed =
SubscriberDetailsMappingImpl.INSTANCE.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.
SubscriberDetailsMappingImpl.INSTANCE.patch(subscriber, new SubscriberDetailsDto(null, 37));
// Invalid(NonEmptyList[email: must not be null])
patch applies every projected component: a null reference read becomes a located FieldError (must not be null), never "leave unchanged". The REST-PATCH contract (null means absent, keep the current value) is the sparse UpdateSpec tier on a bean wire; this tier is its dense, record-shaped complement for writing a validated sub-view onto a bigger record.
Everything the full tier resolves is available on the projected components: explicit leaves (beating identity, so a ValidatedPrism<X, X> can normalise), nested specs (failures compose into dotted paths), and List/Optional/Map lifting. Nulls locate through the nesting too: a nested wire value delegates to the nested spec's parse, whose reference legs carry the same guard, so patch(customer, new CustomerPatchDto(new AddressDto(null))) reports address.zip: must not be null instead of throwing. Only derived fields stay rejected. At the Spring boundary the result is already the 422 leg's shape: return it as-is. Like every tier, this one is law-checked:
MappingLaws.assertMappingLaws(
SubscriberDetailsMappingImpl.INSTANCE::patch,
SubscriberDetailsMappingImpl.INSTANCE::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: a normalising leaf rewrites the wire form by design, the same weakening the fallible full tier accepts (it, too, drops the build-after-parse law).
- The tiers tell the truth:
asIso,asLens,patch,asValidatedPrism, orupdateFromexist only where the correspondences lawfully support them - A lossless parse is still guarded: hostile bindings become located invalids;
reverseGetis for in-memory round trips only - A validating projection gets
patch, not a fake lens: every projected component validated, every bad field located, unprojected components untouched by construction - Every tier is law-checked: one
MappingLawsoverload per tier, the same harness the library's own build runs
- Testing With hkj-test - The law harness
MappingLawsbelongs to - Beans and Sparse PATCH - The sparse
updateFromtier - Injecting, Testing, and Diagnostics - Registering a tier's surface as a bean
Previous: Nesting, Containers, and Sealed Hierarchies Next: Beans and Sparse PATCH