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 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:
| 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 scoped accordingly. 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. Your own specs get the same guarantee with one call from a test, where hkj-test lives:
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 no-parse check). 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 as the fallible full tier.
- 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