Standard Codecs and Shared Vocabulary
The stock conversions for the standard families, and the mix-in pattern that shares them across an API.
A typical DTO boundary converts the same handful of families every time: identifiers, dates, enums, money. Writing a ValidatedPrism by hand for each would be busywork, and writing it lawfully (accepting exactly the spelling it renders) is subtle. StandardCodecs ships that vocabulary ready-made, and a plain mix-in interface shares it, together with your own leaves and renames, across every spec in an API.
- Mapping the standard conversion families (identifiers, dates, enums, money) with one factory call each
- Why the codecs accept canonical forms only, and how the formatter overloads serve differently-canonical wires
- Wrapping a lenient, throwing JDK parser lawfully with
ValidatedPrism.canonical - Sharing leaves and renames across specs with plain mix-in interfaces
The code on this page is StandardCodecsBook.java and RecordMappingBook.java - the page includes them directly, so they are compiled and run by the build.
Standard codecs
The common conversion families need no hand-written leaves: StandardCodecs ships one factory per family, so a typical DTO boundary maps out of the box:
enum OrderStatus {
NEW,
PAID,
CANCELLED
}
record Order(UUID id, LocalDate placedOn, OrderStatus status, BigDecimal total) {}
record OrderDto(String id, String placedOn, String status, String total) {}
@GenerateMapping
interface OrderMapping extends MappingSpec<Order, OrderDto> {
default ValidatedPrism<String, UUID> id() {
return uuid();
}
default ValidatedPrism<String, LocalDate> placedOn() {
return localDate();
}
default ValidatedPrism<String, OrderStatus> status() {
return enumByName(OrderStatus.class);
}
default ValidatedPrism<String, BigDecimal> total() {
return bigDecimal();
}
}
| Factory | Wire ↔ domain |
|---|---|
uuid() | String ↔ UUID |
uri() | String ↔ URI |
localDate() / localDate(DateTimeFormatter) | String ↔ LocalDate |
instant() | String ↔ Instant (UTC, Z) |
offsetDateTime() / offsetDateTime(DateTimeFormatter) | String ↔ OffsetDateTime |
enumByName(Class) | String ↔ any enum, by exact constant name |
bigDecimal() | String ↔ BigDecimal, plain notation, scale preserved |
intFromString() / longFromString() / doubleFromString() | String ↔ boxed number, canonical spellings only ("2" is not a canonical double; "2.0" is) |
booleanStrict() | String ↔ Boolean, exactly true/false |
currency() | String ↔ Currency (ISO 4217) |
locale() | String ↔ Locale (BCP 47 tag) |
Every parse failure is a located FieldError with a copy-worthy message, so the codecs feed the 422 leg unchanged, and the enum message names the permitted constants:
Validated<NonEmptyList<FieldError>, Order> parsed =
OrderMappingImpl.INSTANCE.parse(new OrderDto("NOPE", "28/07/2026", "SHIPPED", "1E+3"));
assertThatValidated(parsed).isInvalid();
assertThat(rendered(parsed))
.containsExactly(
"id: not a UUID (expected e.g. 123e4567-e89b-12d3-a456-426614174000)",
"placedOn: not an ISO-8601 date (expected e.g. 2026-07-28)",
"status: unknown OrderStatus (expected one of NEW, PAID, CANCELLED)",
"total: not a number in plain notation (expected e.g. 123.45)");
Canonical forms only
Each codec accepts exactly the form it renders, honouring the ValidatedPrism section law. A case-folded UUID, a leading zero, scientific notation or a lowercase language tag is a located rejection, never a silent normalisation, so whatever parse accepts, build reproduces byte-for-byte (build(parse(s).get()) == s whenever s parses).
Silent normalisation is data mutation nobody asked for. A mapper that quietly lowercases a UUID or reformats a timestamp makes an echo endpoint return different bytes than it received, breaks cache keys and payload signatures, and bakes a client's spelling bug into the contract without anyone deciding to. The strictness here is not pedantry: it is the property that makes round trips provable, and every codec is law-checked to accept exactly what it renders. When a producer legitimately speaks a different canon, you do not weaken the law; you declare that canon (below) and keep the same guarantee on their spelling.
The date-time canons collide with two very common producers, and the fix is the same for both:
| Producer | Sends | Default canon says | Fix |
|---|---|---|---|
Python isoformat(), PostgreSQL JSON | 2026-07-28T12:34:56+00:00 | rejected: a zero offset must be spelled Z | formatter overload |
JavaScript toISOString() | 2026-07-28T12:34:56.000Z | rejected: a zero fraction renders as no fraction at all, so .000Z never round-trips | formatter overload |
Non-zero fractions expose that the two date-time codecs have different canons, each honestly its own render: instant() follows Instant.toString()'s three-digit groups (.500Z parses, .5Z is rejected), while offsetDateTime() renders without trailing zeros (.5Z parses, .500Z is rejected). The rule never changes, only the render: each codec accepts exactly the spelling it produces.
The formatter overload makes the canonical form theirs:
final class WireFormats {
// Serves a JavaScript toISOString() producer: fixed three-digit millis, Z for UTC.
// The canon is the formatter's, not that producer's output set: any spelling the
// pattern round-trips (a +01:00 offset, say) is accepted as lawfully canonical.
static final ValidatedPrism<String, OffsetDateTime> JS_WIRE =
offsetDateTime(DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSSXXX"));
// Serves a +00:00-spelling producer (Python isoformat()): xxx renders the zero
// offset as +00:00
static final ValidatedPrism<String, OffsetDateTime> PYTHON_WIRE =
offsetDateTime(DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ssxxx"));
private WireFormats() {}
}
Two properties of a formatter canon are worth knowing. The canon is the pattern's, not the producer's output set: any spelling the pattern round-trips is accepted, so JS_WIRE admits a +01:00 offset a real toISOString() would never emit, lawfully. And the pattern fixes the canon's precision: extra fractional digits on the wire are rejected (they do not fit the pattern), while a domain value carrying finer precision than the pattern renders truncated on build, which is a non-injective render, the obligation the laws page leaves with you. Pick a pattern whose precision matches what the domain actually stores, and check a custom canon with the laws.
Your own canon: ValidatedPrism.canonical
The same move covers any differently-canonical wire. An uppercase-UUID producer (SQL Server) is not forbidden by the law; only accepting both cases through one leaf is. ValidatedPrism.canonical supplies the guard such a leaf needs: the lenient, throwing UUID.fromString is fine, because the render defines the canon and the per-value guard rejects every spelling it cannot reproduce:
record Asset(UUID id, String label) {}
record AssetDto(String id, String label) {}
@GenerateMapping
interface AssetMapping extends MappingSpec<Asset, AssetDto> {
// An uppercase-UUID wire (SQL Server): the lenient, throwing parse is fine,
// because the render defines the canon and the per-value guard rejects
// every spelling it cannot reproduce.
default ValidatedPrism<String, UUID> id() {
return ValidatedPrism.canonical(
"not an uppercase UUID",
UUID::fromString,
uuid -> uuid.toString().toUpperCase(Locale.ROOT));
}
}
Conversions the vocabulary does not cover stay hand-written leaves: ValidatedPrism.canonical(...) where a throwing parser and a render exist, ValidatedPrism.of(...) for full control. The processor never applies a codec implicitly; a conversion exists only where a spec declares it.
- The number and boolean codecs focus the box types: a
ValidatedPrism<String, int>cannot exist, so anintcomponent cannot take a leaf; declare itInteger(the mapper rejects the mismatch at compile time either way). - Under the star import, a leaf whose component shares a factory's name (
currency,locale,uuid) must qualify the call (return StandardCodecs.currency();) because the leaf method itself is the nearercurrency()and an unqualified call recurses.
Shared vocabulary: mix-in interfaces
The same rename or the same leaf tends to recur across an API's specs: every wire calls it fullName, every email parses the same way. Move the shared members onto a plain interface and extend it alongside MappingSpec:
// Plain vocabulary - not a spec itself. Any spec whose records share these
// shapes extends it alongside MappingSpec.
interface ContactVocabulary {
@MapField(to = "fullName")
String name();
default ValidatedPrism<String, EmailAddress> email() {
return EmailCodecs.EMAIL;
}
}
record Client(String name, EmailAddress email) {}
record ClientDto(String fullName, String email) {}
@GenerateMapping
interface ClientMapping extends ContactVocabulary, MappingSpec<Client, ClientDto> {}
record Supplier(String name, EmailAddress email, String phone) {}
record SupplierDto(String fullName, String email, String phone) {}
@GenerateMapping
interface SupplierMapping extends ContactVocabulary, MappingSpec<Supplier, SupplierDto> {}
// One vocabulary, two mappings - the inherited rename and leaf apply to both:
ClientMappingImpl.INSTANCE.parse(new ClientDto("Ada Lovelace", "ada@example.org"));
SupplierMappingImpl.INSTANCE.parse(new SupplierDto("Acme Ltd", "sales@acme.example", "01"));
An inherited member counts exactly as if it were declared on the spec: renames, leaves and derived fields, collected across the whole hierarchy (a mix-in may extend further mix-ins, and a diamond counts once). Precedence is Java's own: a member re-declared on the spec (or on a nearer mix-in) hides the one it overrides.
A mix-in may be generic: its members are read under the spec's instantiation, so Emails<T> extended as Emails<EmailAddress> contributes ValidatedPrism<String, EmailAddress>. See Generic mix-ins.
Two shapes are rejected, each naming the offender:
- a mix-in that is itself a mapping spec (directly or transitively extends
MappingSpec/UpdateSpec): a mix-in shares vocabulary, a spec generates an Impl, and inheriting one spec from another would conflate the two; - a generic mix-in reached raw, either because the spec writes
extends Emailsor because an interface between them does: raw erases every member of the type below it, so the spec would inherit what it never declared.
Diagnostics about an inherited member name its declaring interface, abstract method 'bogus' (inherited from 'BrokenVocabulary') is neither a rename nor a leaf, so the fix points at the right file.
Conflicting inherited default methods are already a javac error before the processor runs. The one case javac leaves open, unrelated mix-ins both declaring the same abstract rename (override-equivalent abstracts may coexist, JLS 9.4.1), folds into a single rename when the targets agree and is rejected with a diagnostic naming both interfaces when they conflict. Interface static helpers are not inherited (JLS 8.4.8), so factory methods on a mix-in stay inert.
Mix-ins compose with the rest of the feature: threaded generic specs can extend mix-ins, generic ones included, at their own type parameters, and UpdateSpec mappings inherit vocabulary the same way, element leaves included, so the leaf a full spec lifts over a List serves its PATCH sibling unchanged. @GenerateMerge specs still declare everything directly.
- The standard families are one factory call each:
StandardCodecscovers identifiers, dates, enums, numbers, and money with lawful, located codecs - Canonical forms only: each codec accepts exactly the spelling it renders; a differently-canonical wire takes the formatter overload or a
ValidatedPrism.canonicalleaf canonicalmakes lenient parsers lawful: the render defines the canon and the per-value guard rejects every spelling it cannot reproduce- Mix-ins share the vocabulary: one plain interface serves every spec, PATCH siblings included; nothing is ever applied implicitly
- Validated Prisms - The section law the codecs are built to honour
- Record Mapping Basics - How leaves attach to a spec
- Beans and Sparse PATCH - The PATCH sibling that lifts the same element leaves
Previous: Record Mapping Basics Next: Nesting, Containers, and Sealed Hierarchies