Generic Specs
Concrete, threaded, and element-mapped: three ways to map a generic record, one rule for how you reach the Impl.
A generic record (Page<T>, Result<E, A>) raises a question a monomorphic pair never does: is the mapping for one instantiation, for all of them, or parameterised by codecs the spec cannot know? All three are supported, and which one you have determines how the generated Impl is accessed. (No generic records at your boundary? Skip ahead to Merge and Error Envelopes and return when a Page<T> appears.)
- Mapping a concrete instantiation, where the whole toolkit applies under the substitution
- Threading a spec's own type parameters so one mapping serves every instantiation
- Element-mapped specs: abstract leaves deferred to a constructor-supplied
of(...)factory - The one rule behind
INSTANCE,instance(), andof(...)
The code on this page is RecordMappingBook.java - the page includes it directly, so it is compiled and run by the build.
Concrete instantiations
As a concrete instantiation, name the type arguments in the spec and every component classifies under that substitution, so the whole toolkit (leaves, nesting, containers, the null doctrine, index location) applies unchanged:
record Page<T>(List<T> items, int total) {}
record PageDto<T>(List<T> items, int total) {}
@GenerateMapping
interface CustomerPageMapping extends MappingSpec<Page<Customer>, PageDto<CustomerDto>> {}
CustomerPageMappingImpl.INSTANCE.parse(
new PageDto<>(
List.of(new CustomerDto("Ada", "ada@corp.example"), new CustomerDto("Bob", "nope")),
2));
// Invalid(NonEmptyList[items.1.email: not an email address])
An instantiated mapping registers like any other, so Report(Page<Customer> results) nests it automatically. A threaded spec nests too: a use site's type arguments unify against the spec's declared pair, so Report(Page<String> results) resolves PageMapping<T> as PageMappingImpl.<String>instance(), and a generic outer spec may thread its own variable straight through.
Threaded specs
As a threaded spec, declare the spec generic in its own type parameters and one mapping serves every instantiation. Same-variable elements copy by identity under the null-element scan (a null element is items.1: must not be null, never a smuggled null), and the whole surface (build, parse, asIso on a lossless pair) is generic:
@GenerateMapping
interface PageMapping<T> extends MappingSpec<Page<T>, PageDto<T>> {}
// one Impl for every T: PageMappingImpl.<String>instance(), .<Integer>instance(), ...
// One generic Impl serves every instantiation; identity elements copy through.
Page<String> tags = new Page<>(List.of("fp", "hkt"), 2);
PageDto<String> tagsDto = PageMappingImpl.<String>instance().build(tags);
Validated<NonEmptyList<FieldError>, Page<Integer>> counts =
PageMappingImpl.<Integer>instance().parse(new PageDto<>(List.of(1, 2, 3), 3));
A generic Impl cannot carry a typed static INSTANCE, so it follows the library's generic-singleton convention (EitherMonad.instance()): one stateless cached instance behind PageMappingImpl.instance(). Multi-parameter and bounded specs thread too (ResultMapping<E, A>, RankedMapping<T extends Number>), and a same-typed default leaf (ValidatedPrism<T, T>) still routes elements.
In assignment context the witness is inferred, so plain instance() reads naturally; the explicit PageMappingImpl.<String>instance() form is only needed where Java cannot infer:
PageMapping<String> inferredWitness = PageMappingImpl.instance(); // witness inferred
One rule, three access shapes
The three access shapes are one rule, not three conventions: how much state does the Impl carry?
| Spec shape | Access | Why |
|---|---|---|
| Concrete | XImpl.INSTANCE | stateless, monomorphic: a plain constant |
| Threaded generic | XImpl.<T>instance() | stateless but generic: a typed constant is impossible, so the cached singleton sits behind a generic accessor (the EitherMonad.instance() convention) |
| Element-mapped | XImpl.of(prisms) | carries its leaf prisms as state: every call is a fresh, immutable instance |
Element-mapped specs
The third form is element-mapped: thread the two sides under different variables (Page<T> ↔ PageDto<TDto>) and declare the element mapping as an abstract leaf. Nothing on the spec can parse a TDto into a T, so the generated Impl defers it: each abstract leaf becomes a constructor-supplied field behind a public of(...) factory, one ValidatedPrism per leaf in declaration order:
// The element mapping is deliberately open: an abstract leaf, supplied at of(...) time.
@GenerateMapping
interface CodecPageMapping<T, TDto> extends MappingSpec<Page<T>, PageDto<TDto>> {
ValidatedPrism<TDto, T> items();
}
// One spec, any element codec: each abstract leaf arrives as a prism through of(...).
Validated<NonEmptyList<FieldError>, Page<EmailAddress>> mail =
CodecPageMappingImpl.of(EmailCodecs.EMAIL)
.parse(new PageDto<>(List.of("ada@example.org", "nope"), 2));
// Invalid(NonEmptyList[items.1: not an email address])
The Impl carries the prisms as state, so there is no singleton in either spelling: every of(...) call is a fresh, immutable instance.
Element-mapped mappings nest as compositions. A use site whose pair unifies against one resolves each element pair in turn:
- through a leaf on the using spec named after the component (single-leaf specs; a spec with several abstract leaves resolves each pair through the registry),
- or recursively through another registered mapping,
and emits CodecPageMappingImpl.of(entries()).asValidatedPrism() in place. Failures locate through the whole composed path (entries.items.1: not an email address); an unresolvable element pair is a compile error naming the pair and both levers.
Generic mappings are record-to-record only (bean-shaped wires and UpdateSpec mappings stay concrete); raw uses (including raw nested arguments) and wildcards are diagnosed, while array arguments (Page<String[]>) are concrete, map fine, and unify structurally at nested use sites. An abstract leaf belongs to a generic spec: on a concrete or sealed one it is diagnosed, since nothing defers its parser.
- Three generic forms: concrete instantiations, threaded specs, and element-mapped specs, all three nestable
- Access follows state:
INSTANCE(monomorphic),instance()(generic singleton),of(...)(carries its element prisms) - Element-mapped specs defer what they cannot know: each abstract leaf becomes a constructor-supplied
ValidatedPrism - The boundaries are diagnosed: record-to-record only, no raw types or wildcards, and abstract leaves only where something defers them
- Nesting, Containers, and Sealed Hierarchies - How generic mappings register and nest
- Record Mapping Basics - The null-element scan same-variable elements copy under
- Injecting, Testing, and Diagnostics - Registering an element-mapped Impl as a bean
Previous: Beans and Sparse PATCH Next: Merge and Error Envelopes