Absent Fields and Record Invariants
Let a field's null mean absent, and get a record constructor's refusal back as an error, not an exception.
parse turns every null it reads from the wire into an error that names the field (Null has an address, not a stack trace). This page covers two things that rule leaves open. Declare a component whose null means absent with @OptionalBridge, and the domain receives an empty Optional. And when a record's own constructor refuses a value, parse returns the constructor's message at the record's path instead of throwing. For a PATCH endpoint, where an omitted field keeps its current value, see Sparse PATCH.
- Declare a field whose
nullmeans absent with@OptionalBridge, and predict whatparsedoes with it - Predict where a constructor's refusal is reported, and what the client reads
The code on this page is AbsenceBook.java and its AbsenceBookTest.java - the page includes them directly, so they are compiled and run by the build.
Optional fields: @OptionalBridge
Sometimes a wire null is not a defect: it is how the client says this field is absent. A domain Optional<String> nickname against a wire String nickname is the shape. The DTO keeps a plain String, since DTO fields rarely use Optional, so absence arrives as null. An omitted property and an explicit "nickname": null both arrive as null, so both read as absent.
Say so per component with @OptionalBridge, and the pair maps in both directions:
record Member(String name, Optional<String> nickname, Optional<EmailAddress> altEmail) {}
// The wire carries optional data the way a JSON binder does: a nullable component.
record MemberDto(String name, @Nullable String nickname, @Nullable String altEmail) {}
@GenerateMapping
interface MemberMapping extends MappingSpec<Member, MemberDto> {
// No conversion: the marker restates the component and the value is copied.
@OptionalBridge
Optional<String> nickname();
// A conversion: the same annotation on the component's leaf, over the types inside the Optional.
@OptionalBridge
default ValidatedPrism<String, EmailAddress> altEmail() {
return EmailCodecs.EMAIL;
}
}
build : empty ──▶ null parse : null ──▶ Optional.empty()
present ──▶ the value value ──▶ Optional.of(value)
(through its leaf or spec, if any)
MemberMappingImpl memberMapping = MemberMappingImpl.INSTANCE;
// Absence travels as null in both directions; a present value still validates.
MemberDto wire = memberMapping.build(new Member("Ada", Optional.empty(), Optional.empty()));
// MemberDto[name=Ada, nickname=null, altEmail=null]
Validated<NonEmptyList<FieldError>, Member> absent =
memberMapping.parse(new MemberDto("Ada", null, null));
// Valid(Member[name=Ada, nickname=Optional.empty, altEmail=Optional.empty])
Validated<NonEmptyList<FieldError>, Member> badAltEmail =
memberMapping.parse(new MemberDto("Ada", "countess", "not-an-email"));
// Invalid(NonEmptyList[altEmail: not an email address])
Where the annotation goes depends on one question: does the value inside the Optional need a leaf?
The value inside the Optional | Where the annotation goes | What it declares |
|---|---|---|
| Copies as-is, or is a record with a spec of its own | An abstract marker method named after the domain component | @OptionalBridge Optional<String> nickname(); (the return type restates the component) |
| Converts through a leaf | That component's own default leaf | @OptionalBridge default ValidatedPrism<String, EmailAddress> altEmail(), over the types inside the Optional |
Use one placement or the other: Java cannot declare both, since they share a name. A record with a spec of its own needs no leaf, since a present value nests through that spec: Optional nested objects.
build writes null into the bridged wire component for an absent value, so declare it to take one: @Nullable String nickname. A bridged component must take null lists the declarations the processor refuses.
import java.util.Optional;
import org.higherkindedj.optics.annotations.GenerateMapping;
import org.higherkindedj.optics.annotations.MappingSpec;
record Reader(String name, Optional<String> nickname) {}
record ReaderDto(String name, String nickname) {}
@GenerateMapping
interface ReaderMapping extends MappingSpec<Reader, ReaderDto> {}
The processor says:
@GenerateMapping: target field 'ReaderDto.nickname' has no usable source. The types differ
(java.lang.String vs java.util.Optional<java.lang.String>) and no matching leaf method was
found. Found on Reader: [name, nickname]. Add '@OptionalBridge
java.util.Optional<java.lang.String> nickname();' to the spec, so an absent value reads as a
null wire component and back. Add 'default ValidatedPrism<java.lang.String,
java.util.Optional<java.lang.String>> nickname()' to the spec.
The refusal names the bridge first, and offers a whole-Optional leaf second. The processor refuses the annotation on such a leaf rather than ignoring it (is declared over the whole Optional). On a leaf, the annotation takes the types inside the Optional, as altEmail shows.
On a record wire the processor never infers absence, since on most record wires a null really is a defect. Of the two fixes the refusal offers, only @OptionalBridge gives the field an absent state. The whole-Optional leaf compiles too, but a null still becomes must not be null, so the client can never leave the field out. That leaf suits a wire that spells absence another way, such as an empty string.
A bean wire bridges automatically, so it needs no annotation: @OptionalBridge on a bean wire is redundant. A bridged component also changes which methods the spec generates, since absence is a real correspondence, not a copy: Where a bean or a bridged component lands.
A record's own invariants
A domain record often guards itself, with a compact constructor that throws when its components disagree. parse keeps that guard, but reports a refusal as an error instead of throwing it. Once every component of the record has parsed, the generated code calls its canonical constructor. A RuntimeException the constructor throws becomes a FieldError at the record's own path, carrying the exception's message:
// The domain guards itself: a stay must end after it starts. The wire carries no such rule.
record Stay(LocalDate checkIn, LocalDate checkOut) {
Stay {
if (!checkOut.isAfter(checkIn)) {
throw new IllegalArgumentException("checkOut must be after checkIn");
}
}
}
record StayDto(String checkIn, String checkOut) {}
record Reservation(String guest, List<Stay> stays) {}
record ReservationDto(String guest, List<StayDto> stays) {}
@GenerateMapping
interface StayMapping extends MappingSpec<Stay, StayDto> {
default ValidatedPrism<String, LocalDate> checkIn() {
return StandardCodecs.localDate();
}
default ValidatedPrism<String, LocalDate> checkOut() {
return StandardCodecs.localDate();
}
}
@GenerateMapping
interface ReservationMapping extends MappingSpec<Reservation, ReservationDto> {}
Validated<NonEmptyList<FieldError>, Reservation> reservation =
ReservationMappingImpl.INSTANCE.parse(
new ReservationDto(
null,
List.of(
new StayDto("2026-03-01", "2026-03-04"),
new StayDto("2026-03-09", "2026-03-07"))));
// Invalid(NonEmptyList[guest: must not be null, stays.1: checkOut must be after checkIn])
ReservationMapping uses StayMapping for each stay without being told: Nesting explains how. The second stay fails at stays.1, and the missing guest is still reported beside it. The rules:
- The record is the address. A cross-field invariant belongs to no single component, so it locates where the record does: under the component holding it (
stays.1), or at the top level, where the 422 renders an empty"path": "". - The constructor runs last. It runs only once every component has parsed. So a record reports its components' errors or its invariant, never both, and a client may meet the invariant on a second attempt.
- Write the message for the client. The 422 sends it verbatim, unlike the exception messages Spring Boot hides by default, so keep internal detail out. An exception without a message, or with a blank one, reads
not a valid Stay. - Put a one-field rule in a leaf. It then locates at the field, and accumulates with the record's other errors.
Any RuntimeException the constructor throws is reported this way, bugs included. The null check keeps a null out of the constructor, but a constructor that divides by zero fails the same way: the client reads the exception's message, and its stack trace is dropped. Keep the constructor to checks on its arguments.
Other generated methods that return errors report a refusal the same way, and the few that cannot return one let the exception through: Which surfaces a constructor's refusal reaches.
You can now accept requests that leave some fields out, and keep a record's own checks without turning a bad request into an exception.
A patron may leave out a birthday, and one they send must be an ISO date that StandardCodecs.localDate() parses:
record Patron(String name, Optional<LocalDate> birthday) {}
record PatronDto(String name, @Nullable String birthday) {}
Which declaration on PatronMapping does that?
@OptionalBridge Optional<LocalDate> birthday();@OptionalBridge default ValidatedPrism<String, LocalDate> birthday(), returningStandardCodecs.localDate()@OptionalBridge default ValidatedPrism<String, Optional<LocalDate>> birthday(), a leaf over the wholeOptional- The same whole-
Optionalleaf, without the annotation
Answer and why
Answer and why
2. The date inside the Optional needs a leaf, so the annotation goes on that leaf, over the types inside the Optional. The marker (1) is only for a value that copies or has a spec of its own, and nothing copies a String into a LocalDate. The processor refuses the annotation on a whole-Optional leaf (3). Without it (4), that leaf compiles but reads a null as must not be null, so the birthday could never be left out:
@GenerateMapping
interface PatronMapping extends MappingSpec<Patron, PatronDto> {
@OptionalBridge
default ValidatedPrism<String, LocalDate> birthday() {
return StandardCodecs.localDate();
}
}
PatronMappingImpl patronMapping = PatronMappingImpl.INSTANCE;
assertThatValidated(patronMapping.parse(new PatronDto("Ada", null)))
.hasValue(new Patron("Ada", Optional.empty())); // left out: absent
assertThatValidated(patronMapping.parse(new PatronDto("Ada", "07/03/2026")))
.hasFieldErrors("birthday: not an ISO-8601 date (expected e.g. 2026-07-28)");
Where this lives: Optional fields: @OptionalBridge.
BulkDiscount spreads a basket's discount over its items, and its constructor divides by items:
// A basket's discount, spread over its items: at most 500p off each.
record BulkDiscount(int totalPence, int items) {
BulkDiscount {
if (Math.ceilDiv(totalPence, items) > 500) { // rounds up, so 1001p over 2 items is 501p
throw new IllegalArgumentException("at most 500p off per item");
}
}
}
record BulkDiscountDto(int totalPence, int items) {}
record Basket(String id, BulkDiscount discount) {}
record BasketDto(String id, BulkDiscountDto discount) {}
@GenerateMapping
interface BulkDiscountMapping extends MappingSpec<BulkDiscount, BulkDiscountDto> {}
@GenerateMapping
interface BasketMapping extends MappingSpec<Basket, BasketDto> {}
A client sends a discount with items set to 0. What does BasketMappingImpl.INSTANCE.parse(new BasketDto("B-7", new BulkDiscountDto(1000, 0))) report?
- Nothing: the
ArithmeticExceptionpropagates out ofparse discount: not a valid BulkDiscountdiscount: / by zerodiscount.items: / by zero
Answer and why
Answer and why
3. Any RuntimeException counts, bugs included, so the constructor's ArithmeticException becomes an error carrying its own message, / by zero. The fallback not a valid BulkDiscount is only for an exception with no message. The record is the address, so the error sits at discount, the component holding the record, not at items:
assertThatValidated(
BasketMappingImpl.INSTANCE.parse(new BasketDto("B-7", new BulkDiscountDto(1000, 0))))
.hasFieldErrors("discount: / by zero");
A client cannot act on / by zero. Check items first, and throw with a message written for the client.
Where this lives: A record's own invariants.
- Absence is declared, never guessed:
@OptionalBridgeopts oneOptionalcomponent into readingnullas absent, and on a record wire nothing else does - A record's own invariant is located too: an exception from its constructor becomes a
FieldErrorat the record's path, beside the errors from the rest of the value, once its own components have parsed
- Sparse PATCH: When an omitted field should keep its current value, not become empty
- Optional nested objects: A bridged component whose element has a spec of its own
- The 422 leg: How these errors reach the client as one HTTP response
- The null contract, precisely: What the null guard reaches, and which nulls stay the caller's bug
Previous: Standard Codecs and Shared Vocabulary Next: Nesting, Containers, and Sealed Hierarchies