Multi-Edit and Sparse Updates

N Edits in One Operation, and the REST PATCH Shape

See Example Code

The code on this page is MultiEditBook.java - the page includes it directly, so it is compiled and run by the build.

Apply N independent edits at different paths in one reusable operation, including the sparse, all-errors-at-once REST PATCH shape.

What You'll Learn

  • Combining several edits at different paths into one reusable operation with Edits.combine
  • Writing sparse updates where a null field means "leave it alone", with no if per field
  • Building a REST PATCH with Edits.accumulate that validates every field and reports all the bad ones at once, not just the first
  • The two-phase model: validate everything first, then apply the writes only if all of them passed
  • Editing fields a record's constructor checks together, so it sees only the values the edits end on
  • How the pure and validating edits are kept apart at compile time, and when overlapping paths need one atomic edit instead

The problem

An optic edits one path at a time (FocusPath.set, Setter.modify). But the everyday case is applying several edits at once, the classic example being a REST PATCH that tidies the email, trims the SKU, and bumps the quantity. By hand that means threading the value through every step, guarding each optional field with an if, and (if you validate at all) throwing on the first bad field:

    Order updated = order;
    if (req.email() != null) {
      updated = updated.withEmail(req.email().toLowerCase()); // thread the result...
    }
    if (req.sku() != null) {
      updated = updated.withSku(req.sku().trim()); // ...through every step
    }
    if (req.qtyDelta() != null) {
      updated = updated.withQuantity(updated.quantity() + req.qtyDelta());
    }
    // And if the email was malformed? You throw on the first bad field and never see the rest.

Three pains recur: one if per optional field, the value re-threaded by hand at every step, and validation that stops at the first error instead of collecting them all.

The solution: two entry points

The org.higherkindedj.optics.edit package folds all of that into two operations. Which one you reach for depends only on whether any edit can fail:

Entry pointReach for it whenReturns
Edits.combine(...)every edit is always safe (no validation)one reusable Update<S>
Edits.accumulate(...)some edits validate their input (a REST PATCH)a patch you apply to get Validated<NonEmptyList<FieldError>, S>

Those are the whole API, with one more form of accumulate for fields a constructor checks together. The rest of this page is how each one behaves, and how the compiler keeps a validating edit from slipping into combine by accident.


Pure multi-edit: Edits.combine

Each Edit factory pairs an optic (a FocusPath or a Setter) with a value or function. combine folds them (via the Update monoid) into one named, reusable transformation:

import static org.higherkindedj.optics.edit.Edit.*;

    Update<Order> normalise =
        Edits.combine(modify(EMAIL, String::toLowerCase), modify(SKU, String::trim));

    Order orderA = new Order("ORD-1", "A@B.COM", " sku ", 1);
    Order orderB = new Order("ORD-2", "C@D.COM", " sku2 ", 2);

    Order a = normalise.apply(orderA);
    Order b = normalise.andThen(APPLY_DISCOUNT).apply(orderB); // Update composes further

Only pure Edits fit combine's signature; a fallible edit is rejected at compile time, so validation failures can never be silently dropped.


Sparse updates: absent means "leave it alone"

The …IfPresent factories treat null as absent: the edit contributes the identity update, so a sparse request DTO lands one-to-one with no if ceremony:

    Edit<Order> number = setIfPresent(ORDER_NUMBER, req.orderNumber()); // null -> no-op
    Edit<Order> quantity = modifyIfPresent(QUANTITY, req.qtyDelta(), (delta, qty) -> qty + delta);

Each request field maps to exactly one slot; an absent field simply contributes nothing to the fold:

flowchart TD
    Req(["PatchRequest<br/>email: a@b.example, sku: null, qtyDelta: 3"])
    Req --> E(["email present<br/>write it"])
    Req --> S(["sku absent<br/>identity, no change"])
    Req --> Q(["qtyDelta present<br/>qty += 3"])
    E --> Out(["order': email and quantity changed,<br/>sku untouched"])
    S --> Out
    Q --> Out

    classDef tier fill:#a6d189,stroke:#40a02b,color:#232634
    classDef out fill:#e5c890,stroke:#df8e1d,color:#232634
    class Req,E,S,Q tier
    class Out out

Absent and null are deliberately the same

A sparse edit cannot clear a field: setIfPresent(path, null) means "no change requested", not "set to null". This suits non-null domain models; if a field must be clearable, model the cleared state explicitly (e.g. Maybe) and set it. The functions given to modifyIfPresent/parseIfPresent are never invoked with null.


Validated PATCH: Edits.accumulate

parseIfPresent parses the incoming value first, and a generated path locates any failure automatically from its own label (.at(label) prepends further context, exactly as FieldError.at composes paths). The parser you hand it has exactly the shape of a ValidatedPrism's parse: define the boundary once as a prism (gaining the total render-back and the round-trip laws) and pass Email::parse here. accumulate validates every edit independently and reports all bad fields at once:

    Validated<NonEmptyList<FieldError>, Order> patched =
        Edits.accumulate(
                setIfPresent(ORDER_NUMBER, req.orderNumber()),
                parseIfPresent(EMAIL, req.email(), Email::parse),
                modifyIfPresent(QUANTITY, req.qtyDelta(), (delta, qty) -> qty + delta))
            .apply(order);
    // Invalid(NonEmptyList[email: not an address])
    //   <- or Valid(order) with only the present fields changed

Errors accumulate in edit order on the NonEmptyList channel, exactly like the accumulating assembly, but as a homogeneous fold, so there is no arity ceiling.

Generated paths label themselves

A path from a @GenerateFocus companion carries its record-component name as a segment (OrderFocus.email() → "email"); composing paths concatenates segments (customer.via(address).via(zip) → "customer.address.zip", surfaced by segments()/pathString()), and parseIfPresent locates failures with them automatically: no .at(...) needed for generated paths. An explicit .at(label) still prepends outward, for hand-written optics or extra context.

For the railway, applyPath(order) is the ValidationPath twin of apply(order), and toValidated() exposes the folded Update itself for reuse.

Generate this when the shape is regular

When the request DTO's fields line up one-to-one with a domain record (the common REST PATCH case), you need not hand-write the fold at all: @GenerateMapping on an UpdateSpec<Domain, Wire> generates this Edits.accumulate over the present fields, in its construct-once form, as updateFrom(wire) : Edits.Accumulated<Domain> (the same apply/applyPath/toValidated surface). Reach for the hand-written Edits here when the edits are irregular (a qtyDelta that modifies, coupled fields, a computed target); reach for UpdateSpec when each present field maps to one slot.


How the split stays compile-time safe

combine accepts only pure edits; accumulate accepts both. That is not a rule you have to remember: it is carried by the type of each edit. set, modify, and the …IfPresent forms produce an Edit (cannot fail); parseIfPresent produces a FallibleEdit (may fail). Passing a FallibleEdit to combine does not compile, so a validation failure can never be silently dropped.

flowchart TD
    FE(["FallibleEdit&lt;S&gt;<br/>may fail: carries Validated&lt;NEL&lt;FieldError&gt;, Update&lt;S&gt;&gt;"])
    ED(["Edit&lt;S&gt;<br/>cannot fail: carries the Update&lt;S&gt; directly"])
    P(["FallibleEdit.Parsed<br/>from parseIfPresent"]) --> FE
    I(["Edit.Infallible<br/>from set, modify, …IfPresent"]) --> ED
    ED --> FE

    C["Edits.combine(Edit…)<br/>only pure edits fit:<br/>a FallibleEdit is a compile error"]
    A["Edits.accumulate(FallibleEdit…)<br/>both fit: a pure edit<br/>is one that always validates"]
    ED --> C
    FE --> A

    classDef tier fill:#a6d189,stroke:#40a02b,color:#232634
    classDef out fill:#e5c890,stroke:#df8e1d,color:#232634
    class FE,ED,P,I tier
    class C,A out

Semantics: validate everything, then write once

An accumulated patch works in two phases:

  1. Validate: each edit's incoming value is checked independently. Validation never sees a source, which is what makes accumulation sound and lets one patch apply to many sources.
  2. Apply: only if every edit validated, the writes run as a single left-to-right fold.
flowchart TD
    subgraph one["Phase 1: validate each edit independently, no source involved"]
        direction TB
        S1(["setIfPresent(SKU, null)<br/>absent → identity"]) --> V1(["Valid, a no-op"])
        S2(["parseIfPresent(EMAIL, raw)<br/>the parser runs"]) --> V2(["Valid(write)<br/>or Invalid(errors)"])
        S3(["modifyIfPresent(QTY, 3)<br/>present → write"]) --> V3(["Valid(write)"])
    end
    V1 --> Q{"every edit Valid?"}
    V2 --> Q
    V3 --> Q
    Q -->|"yes"| Ok(["Phase 2: one left-to-right fold<br/>Valid(order'), only the present fields written"])
    Q -->|"no"| Bad(["Invalid(NEL[email: …])<br/>every bad field, located"])

    classDef tier fill:#a6d189,stroke:#40a02b,color:#232634
    classDef decision fill:#e5c890,stroke:#df8e1d,color:#232634
    classDef bad fill:#e78284,stroke:#d20f39,color:#232634
    class S1,S2,S3,V1,V2,V3,Ok tier
    class Q decision
    class Bad bad

Application order is observable only when paths overlap: disjoint paths commute; an edit at an overlapping path sees the previous edit's result (a modify reads the current value at application time). Genuinely coupled fields belong in one atomic edit (see Coupled Fields and Lens.paired), or, when a record's constructor checks them against each other, onto a focus.


Fields a constructor checks together

Each write through a record's path builds a new record, so the constructor sees every value the fold passes through. A range whose constructor refuses lo > hi cannot move from Range(1, 3) to Range(5, 10) one end at a time: writing lo first builds Range(5, 3), and the constructor throws before hi is written, although the final range is valid.

Edits.accumulate(focus, edits...) takes a Lens to a value carrying the fields the edits set, with no check of its own. The edits write onto that value, and the lens sets it back once, so the constructor sees only the final values:

record Range(int lo, int hi) {
  Range {
    if (lo > hi) {
      throw new IllegalArgumentException("lo > hi");
    }
  }
}

@GenerateFocus
record Ends(int lo, int hi) {} // the fields the edits set, with no check of their own

    Lens<Range, Ends> ends =
        Lens.of(r -> new Ends(r.lo(), r.hi()), (r, e) -> new Range(e.lo(), e.hi()));

    Validated<NonEmptyList<FieldError>, Range> moved =
        Edits.accumulate(
                ends,
                setIfPresent(EndsFocus.lo(), move.lo()),
                setIfPresent(EndsFocus.hi(), move.hi()))
            .apply(new Range(1, 3));
    // Valid(Range[lo=5, hi=10])
    //   <- both ends move together. Moving lo alone would make Range(5, 3), which the
    //      record's own constructor refuses, and the refusal arrives as a located error.
flowchart LR
    subgraph each["accumulate(edits…): one record per edit"]
        direction LR
        A1(["Range(1, 3)"]) -->|"lo = 5"| A2(["Range(5, 3)<br/>the constructor throws"])
    end
    subgraph once["accumulate(ends, edits…): one Range in all"]
        direction LR
        B1(["Ends(1, 3)"]) -->|"lo = 5"| B2(["Ends(5, 3)"]) -->|"hi = 10"| B3(["Ends(5, 10)"]) -->|"set once"| B4(["Range(5, 10)"])
    end

    classDef tier fill:#a6d189,stroke:#40a02b,color:#232634
    classDef bad fill:#e78284,stroke:#d20f39,color:#232634
    class A1,B1,B2,B3,B4 tier
    class A2 bad

The edits validate exactly as they do in accumulate, and their errors are reported alone: the record is constructed only once every edit validated. A RuntimeException that setting the focus back throws is the constructor refusing the final values, and apply reports it as an unlabelled FieldError carrying the exception's message; an exception without a message, or with a blank one, reads not a valid Range. Only that call is guarded: an exception from an edit's own function, or from reading the focus, still propagates. When every edit is absent, the source comes back as it is and the focus is never set. toValidated() hands back an Update that writes onto the focus and sets it back once, and since an Update has no error channel, a refusal throws from it.

The edits' errors are located relative to the focus. Where the focus is a nested component rather than the source's own fields, add the component's name to each edit with .at("range"), so their errors and the source agree on where they are.

@GenerateMapping on an UpdateSpec generates exactly this: its updateFrom writes onto the components a PATCH can set and constructs the domain record once.


Key Takeaways

  • Edits.combine folds pure edits into one reusable Update<S>; compile-time purity
  • …IfPresent + null-as-absent gives sparse updates with no if ceremony: absent contributes the monoid identity
  • Edits.accumulate validates every edit independently and reports all located failures at once, in edit order, with no arity ceiling
  • Two phases: validation is source-independent; writes run left-to-right only when everything validated
  • Overlapping paths see earlier writes; coupled fields should be one atomic edit (Lens.paired)
  • Edits.accumulate(focus, …) writes onto one focus and sets it back once, so a constructor checking fields against each other sees only the final values, and its refusal is an Invalid

Hands-On Learning

Practise the whole model in Tutorial 24: Multi-Edit and Sparse Updates (5 exercises): pure folds, sparse patches, and the all-errors-at-once validated PATCH.

See Also


Previous: Core Type Integration Next: Optics Extensions