Standard Codecs and Shared Vocabulary

The stock leaf vocabulary for the standard families, and the mix-in pattern that shares it 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.

What You'll Learn

  • 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

See Example Code

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();
  }
}

FactoryWire ↔ domain
uuid()StringUUID
uri()StringURI
localDate() / localDate(DateTimeFormatter)StringLocalDate
instant()StringInstant (UTC, Z)
offsetDateTime() / offsetDateTime(DateTimeFormatter)StringOffsetDateTime
enumByName(Class)String ↔ any enum, by exact constant name
bigDecimal()StringBigDecimal, plain notation, scale preserved
intFromString() / longFromString() / doubleFromString()String ↔ boxed number, canonical spellings only ("2" is not a canonical double; "2.0" is)
booleanStrict()StringBoolean, exactly true/false
currency()StringCurrency (ISO 4217)
locale()StringLocale (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 honours the ValidatedPrism section law by accepting exactly the form it renders: an accepted wire value always rebuilds to itself. A case-folded UUID, a leading zero, scientific notation or a lowercase language tag is a located rejection, never a silent normalisation, so build(parse(dto)) round-trips byte-for-byte.

The date-time canon deserves spelling out, because two very common producers collide with it. A zero offset must be spelled Z: 2026-07-28T12:34:56+00:00 (Python's isoformat(), PostgreSQL JSON) parses to UTC, which renders back as Z, so it is rejected. Fractional seconds render without trailing zeros: JavaScript's toISOString() always emits three-digit milliseconds, so .500Z and .000Z are rejected while .5Z parses. Both producers are served lawfully by the formatter overload; the canonical form is then 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.

Two mechanical notes

  • The number and boolean codecs focus the box types: a ValidatedPrism<String, int> cannot exist, so an int component cannot take a leaf; declare it Integer (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 nearer currency() 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.

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: inherited member types are read as declared, and substituting them under an instantiation is not supported yet.

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.

The inheritance edge cases, precisely

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 (non-generic) mix-ins, 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.


Key Takeaways

  • The standard families are one factory call each: StandardCodecs covers 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.canonical leaf
  • canonical makes 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

See Also


Previous: Record Mapping Basics Next: Nesting, Containers, and Sealed Hierarchies