Skip to main content
Version: 2.x

Patching

The Patching system in ZIO Blocks provides a type-safe, serializable way to describe and apply transformations to data structures. Unlike direct mutations or lens-based updates, patches are first-class values that can be serialized, transmitted over the network, stored for audit logs, and composed together.

Overview​

A Patch[S] represents a sequence of operations that transform a value of type S. Because patches use serializable operations and reflective optics for navigation, they enable powerful use cases:

  • Remote Patching — Send patches over the network to update remote state without transmitting entire objects
  • Audit Logs — Record patches as a log of changes for compliance, debugging, or undo functionality
  • CRDT-like Operations — Use commutative operations like increment that can be safely applied in any order
  • Optimistic Updates — Apply patches locally while syncing with a server
  • Schema Evolution — Patches work with the schema system, adapting as data structures evolve
import zio.blocks.schema._
import zio.blocks.schema.patch._

case class Person(name: String, age: Int)
object Person extends CompanionOptics[Person] {
implicit val schema: Schema[Person] = Schema.derived
val name: Lens[Person, String] = optic(_.name)
val age: Lens[Person, Int] = optic(_.age)
}

// Create patches using smart constructors
val patch1 = Patch.set(Person.name, "John")
val patch2 = Patch.increment(Person.age, 1)

// Compose patches
val combined = patch1 ++ patch2

// Apply the patch
val jane = Person("Jane", 25)
val result = combined(jane) // Person("John", 26)

Creating Patches​

Patches are created using smart constructors on the Patch companion object. Each constructor takes an optic to specify the target location and the operation parameters.

Setting Values​

The set operation replaces a value at the specified location:

case class Address(street: String, city: String, zip: String)
object Address extends CompanionOptics[Address] {
implicit val schema: Schema[Address] = Schema.derived
val street: Lens[Address, String] = optic(_.street)
val city: Lens[Address, String] = optic(_.city)
}

case class Person(name: String, address: Address)
object Person extends CompanionOptics[Person] {
implicit val schema: Schema[Person] = Schema.derived
val name: Lens[Person, String] = optic(_.name)
val address: Lens[Person, Address] = optic(_.address)
val city: Lens[Person, String] = optic(_.address.city)
}

// Set a simple field
val setName = Patch.set(Person.name, "Alice")

// Set a nested field
val setCity = Patch.set(Person.city, "San Francisco")

// Set an entire nested record
val newAddress = Address("123 Main St", "NYC", "10001")
val setAddress = Patch.set(Person.address, newAddress)

The set operation works with all optic types:

  • Lens — Sets a single field
  • Optional — Sets a value if the path exists
  • Traversal — Sets all matching elements to the same value
  • Prism — Sets a variant case

Numeric Increments​

The increment operation adds a delta to numeric fields. This is a commutative operation — applying increments in any order produces the same result, making it ideal for distributed systems:

case class Counter(count: Int, total: Long, balance: BigDecimal)
object Counter extends CompanionOptics[Counter] {
implicit val schema: Schema[Counter] = Schema.derived
val count: Lens[Counter, Int] = optic(_.count)
val total: Lens[Counter, Long] = optic(_.total)
val balance: Lens[Counter, BigDecimal] = optic(_.balance)
}

// Increment various numeric types
val addOne = Patch.increment(Counter.count, 1)
val addTen = Patch.increment(Counter.count, 10)
val subtractFive = Patch.increment(Counter.count, -5)

// Works with all numeric types
val addToTotal = Patch.increment(Counter.total, 1000L)
val addToBalance = Patch.increment(Counter.balance, BigDecimal("99.99"))

// Compose multiple increments
val combined = addOne ++ addTen ++ subtractFive
val counter = Counter(0, 0L, BigDecimal(0))
combined(counter) // Counter(6, 0, 0)

Supported numeric types:

  • Int, Long, Short, Byte
  • Float, Double
  • BigInt, BigDecimal

Temporal Operations​

Patches support temporal arithmetic with addDuration and addPeriod:

import java.time._

case class Event(
timestamp: Instant,
scheduledDate: LocalDate,
scheduledTime: LocalDateTime,
duration: Duration
)
object Event extends CompanionOptics[Event] {
implicit val schema: Schema[Event] = Schema.derived
val timestamp: Lens[Event, Instant] = optic(_.timestamp)
val scheduledDate: Lens[Event, LocalDate] = optic(_.scheduledDate)
val scheduledTime: Lens[Event, LocalDateTime] = optic(_.scheduledTime)
val duration: Lens[Event, Duration] = optic(_.duration)
}

// Add duration to an Instant
val postpone1Hour = Patch.addDuration(Event.timestamp, Duration.ofHours(1))

// Add period to a LocalDate
val postpone1Week = Patch.addPeriod(Event.scheduledDate, Period.ofWeeks(1))

// Add both period and duration to a LocalDateTime
val postpone = Patch.addPeriodAndDuration(
Event.scheduledTime,
Period.ofDays(1),
Duration.ofHours(2)
)

// Add duration to a Duration field
import Patch.DurationDummy.ForDuration
val extendDuration = Patch.addDuration(Event.duration, Duration.ofMinutes(30))

String Edits​

The editString operation applies character-level edits to strings:

case class Document(content: String)
object Document extends CompanionOptics[Document] {
implicit val schema: Schema[Document] = Schema.derived
val content: Lens[Document, String] = optic(_.content)
}

// Insert text at position
val insertHello = Patch.editString(
Document.content,
Vector(Patch.StringOp.Insert(0, "Hello "))
)

// Delete characters
val deleteFirst5 = Patch.editString(
Document.content,
Vector(Patch.StringOp.Delete(0, 5))
)

// Append text
val appendBang = Patch.editString(
Document.content,
Vector(Patch.StringOp.Append("!"))
)

// Replace a range (delete then insert)
val replaceWorld = Patch.editString(
Document.content,
Vector(Patch.StringOp.Modify(6, 5, "Universe"))
)

// Combine multiple edits (applied in sequence)
val doc = Document("Hello World")
val edits = Patch.editString(
Document.content,
Vector(
Patch.StringOp.Delete(5, 6), // "Hello"
Patch.StringOp.Append(" there!") // "Hello there!"
)
)
edits(doc) // Document("Hello there!")

String edit operations:

  • Insert(index, text) — Insert text at the given position
  • Delete(index, length) — Delete characters starting at index
  • Append(text) — Append text to the end
  • Modify(index, length, text) — Replace a range with new text

Working with Collections​

Appending Elements​

The append operation adds elements to the end of a sequence:

case class TodoList(items: Vector[String])
object TodoList extends CompanionOptics[TodoList] {
implicit val schema: Schema[TodoList] = Schema.derived
val items: Lens[TodoList, Vector[String]] = optic(_.items)
}

import Patch.CollectionDummy.ForVector

val addItems = Patch.append(
TodoList.items,
Vector("Buy groceries", "Walk the dog")
)

val list = TodoList(Vector("Existing task"))
addItems(list) // TodoList(Vector("Existing task", "Buy groceries", "Walk the dog"))

Works with Vector, List, Seq, IndexedSeq, and LazyList. Use the appropriate implicit:

import Patch.CollectionDummy.ForList
import Patch.CollectionDummy.ForSeq
import Patch.CollectionDummy.ForIndexedSeq
import Patch.CollectionDummy.ForLazyList

Inserting at Index​

The insertAt operation inserts elements at a specific position:

import Patch.CollectionDummy.ForVector

val insertAtStart = Patch.insertAt(
TodoList.items,
0,
Vector("First priority")
)

val insertInMiddle = Patch.insertAt(
TodoList.items,
1,
Vector("Second item", "Third item")
)

val list = TodoList(Vector("A", "B", "C"))
insertInMiddle(list) // TodoList(Vector("A", "Second item", "Third item", "B", "C"))

Deleting Elements​

The deleteAt operation removes elements starting at an index:

import Patch.CollectionDummy.ForVector

// Delete one element at index 1
val deleteOne = Patch.deleteAt(TodoList.items, 1, 1)

// Delete three elements starting at index 0
val deleteThree = Patch.deleteAt(TodoList.items, 0, 3)

val list = TodoList(Vector("A", "B", "C", "D"))
deleteOne(list) // TodoList(Vector("A", "C", "D"))

Modifying Elements​

The modifyAt operation applies a nested patch to an element at a specific index:

case class Task(title: String, priority: Int)
object Task extends CompanionOptics[Task] {
implicit val schema: Schema[Task] = Schema.derived
val title: Lens[Task, String] = optic(_.title)
val priority: Lens[Task, Int] = optic(_.priority)
}

case class Project(tasks: Vector[Task])
object Project extends CompanionOptics[Project] {
implicit val schema: Schema[Project] = Schema.derived
val tasks: Lens[Project, Vector[Task]] = optic(_.tasks)
}

import Patch.CollectionDummy.ForVector

// Create a patch for the nested Task type
val increasePriority = Patch.increment(Task.priority, 1)

// Modify the task at index 0
val modifyFirst = Patch.modifyAt(Project.tasks, 0, increasePriority)

val project = Project(Vector(
Task("Build feature", 1),
Task("Write tests", 2)
))
modifyFirst(project)
// Project(Vector(Task("Build feature", 2), Task("Write tests", 2)))

Working with Maps​

Adding Keys​

The addKey operation adds a new key-value pair to a map:

case class Config(settings: Map[String, Int])
object Config extends CompanionOptics[Config] {
implicit val schema: Schema[Config] = Schema.derived
val settings: Lens[Config, Map[String, Int]] = optic(_.settings)
}

val addTimeout = Patch.addKey(Config.settings, "timeout", 30)
val addRetries = Patch.addKey(Config.settings, "retries", 3)

val config = Config(Map("port" -> 8080))
val combined = addTimeout ++ addRetries
combined(config) // Config(Map("port" -> 8080, "timeout" -> 30, "retries" -> 3))

Removing Keys​

The removeKey operation removes a key from a map:

val removePort = Patch.removeKey(Config.settings, "port")

val config = Config(Map("port" -> 8080, "timeout" -> 30))
removePort(config) // Config(Map("timeout" -> 30))

Modifying Values​

The modifyKey operation applies a nested patch to the value at a specific key:

case class UserSettings(preferences: Map[String, Int])
object UserSettings extends CompanionOptics[UserSettings] {
implicit val schema: Schema[UserSettings] = Schema.derived
val preferences: Lens[UserSettings, Map[String, Int]] = optic(_.preferences)
}

// Create a patch that increments an Int
val incrementValue: Patch[Int] = {
implicit val intSchema: Schema[Int] = Schema[Int]
Patch(
DynamicPatch.root(Patch.Operation.PrimitiveDelta(Patch.PrimitiveOp.IntDelta(10))),
intSchema
)
}

val increaseVolume = Patch.modifyKey(UserSettings.preferences, "volume", incrementValue)

val settings = UserSettings(Map("volume" -> 50, "brightness" -> 80))
increaseVolume(settings) // UserSettings(Map("volume" -> 60, "brightness" -> 80))

Composing Patches​

Patches compose with the ++ operator. The result applies the first patch, then the second:

case class Account(name: String, balance: Int, active: Boolean)
object Account extends CompanionOptics[Account] {
implicit val schema: Schema[Account] = Schema.derived
val name: Lens[Account, String] = optic(_.name)
val balance: Lens[Account, Int] = optic(_.balance)
val active: Lens[Account, Boolean] = optic(_.active)
}

val rename = Patch.set(Account.name, "Premium Account")
val deposit = Patch.increment(Account.balance, 100)
val activate = Patch.set(Account.active, true)

// Compose all patches
val upgrade = rename ++ deposit ++ activate

val account = Account("Basic", 50, false)
upgrade(account) // Account("Premium Account", 150, true)

Composition is associative: (a ++ b) ++ c equals a ++ (b ++ c).

The empty patch acts as an identity element:

val empty = Patch.empty[Account]
val patch = Patch.increment(Account.balance, 100)

(patch ++ empty)(account) == patch(account) // true
(empty ++ patch)(account) == patch(account) // true

Applying Patches​

Basic Application​

The simplest way to apply a patch uses the apply method, which uses Lenient mode and returns the original value on failure:

val patch = Patch.increment(Account.balance, 100)
val account = Account("Test", 50, true)

val result: Account = patch(account) // Account("Test", 150, true)

Application Modes​

For more control, use the overload that takes a PatchMode:

val result: Either[SchemaError, Account] = patch(account, PatchMode.Strict)

PatchMode.Strict​

Fails immediately if any operation cannot be applied:

case class Data(items: Vector[Int])
object Data extends CompanionOptics[Data] {
implicit val schema: Schema[Data] = Schema.derived
val items: Lens[Data, Vector[Int]] = optic(_.items)
}

import Patch.CollectionDummy.ForVector

// Try to delete at an invalid index
val badDelete = Patch.deleteAt(Data.items, 10, 1)
val data = Data(Vector(1, 2, 3))

badDelete(data, PatchMode.Strict)
// Left(SchemaError(...index out of bounds...))

badDelete(data, PatchMode.Lenient)
// Right(Data(Vector(1, 2, 3))) // Operation skipped

Use Strict when you need to know if every operation succeeded.

PatchMode.Lenient​

Skips operations that fail preconditions and continues with the rest:

val patch1 = Patch.deleteAt(Data.items, 10, 1)  // Will fail
val patch2 = Patch.increment(Counter.count, 5) // Will succeed

val combined = patch1 ++ patch2

combined(data, PatchMode.Lenient)
// Skips the invalid delete, applies the increment

Use Lenient for best-effort patching where partial success is acceptable.

PatchMode.Clobber​

Attempts to force operations through, using fallback behaviors:

// Adding a key that already exists
val addExisting = Patch.addKey(Config.settings, "port", 9000)
val config = Config(Map("port" -> 8080))

addExisting(config, PatchMode.Strict)
// Left(SchemaError(...key already exists...))

addExisting(config, PatchMode.Clobber)
// Right(Config(Map("port" -> 9000))) // Overwrites existing key

Use Clobber when you want to force updates regardless of preconditions.

Option-Based Application​

Use applyOption for a simpler API that returns None on any failure:

val patch = Patch.increment(Account.balance, 100)
val result: Option[Account] = patch.applyOption(account)
// Some(Account("Test", 150, true))

Diffing Values​

The diff method computes a minimal patch that transforms one value into another:

val old = Person("Alice", 25)
val new = Person("Alice", 26)

// Compute the difference
val dynamicOld = Schema[Person].toDynamicValue(old)
val dynamicNew = Schema[Person].toDynamicValue(new)
val patch = dynamicOld.diff(dynamicNew)

// Apply to transform old into new
patch(dynamicOld, PatchMode.Strict)
// Right(dynamicNew)

The differ produces minimal patches using type-appropriate operations:

  • Numeric fields — Uses delta operations (+1 instead of set 26)
  • Strings — Uses edit operations when more compact than replacement
  • Records — Only includes changed fields
  • Sequences — Uses LCS algorithm to compute minimal insert/delete operations
  • Maps — Produces add/remove/modify operations for changed entries

Diff Example​

case class User(
name: String,
scores: Vector[Int],
metadata: Map[String, String]
)
object User {
implicit val schema: Schema[User] = Schema.derived
}

val v1 = User(
"Alice",
Vector(10, 20, 30),
Map("level" -> "1", "status" -> "active")
)

val v2 = User(
"Alice",
Vector(10, 25, 30, 40),
Map("level" -> "2", "status" -> "active")
)

val d1 = Schema[User].toDynamicValue(v1)
val d2 = Schema[User].toDynamicValue(v2)
val patch = d1.diff(d2)

// The patch contains:
// - scores: delete at index 1, insert 25 at index 1, append 40
// - metadata["level"]: increment from "1" to "2" (or set if strings)

Serializable Operations​

All patch operations are serializable through the schema system. Each operation type has an implicit Schema instance:

// Patches can be converted to/from DynamicValue
val patch = Patch.increment(Account.balance, 100)
val dynamicPatch = Schema[DynamicPatch].toDynamicValue(patch.dynamicPatch)

// Serialize to JSON, Avro, MessagePack, etc.
import zio.blocks.schema.json._
val json = JsonEncoder.encode(patch.dynamicPatch)

This enables storing patches in databases, sending them over APIs, or logging them for audit purposes.

Operation Reference​

Value Operations​

OperationDescription
Patch.set(optic, value)Set a value at the optic path
Patch.replace(optic, value)Alias for set
Patch.empty[S]Empty patch (identity)

Numeric Operations​

OperationDescription
Patch.increment(optic, delta)Add delta to numeric field

Temporal Operations​

OperationDescription
Patch.addDuration(optic, duration)Add duration to Instant or Duration
Patch.addPeriod(optic, period)Add period to LocalDate or Period
Patch.addPeriodAndDuration(optic, period, duration)Add both to LocalDateTime

String Operations​

OperationDescription
Patch.editString(optic, edits)Apply string edit operations
StringOp.Insert(index, text)Insert text at position
StringOp.Delete(index, length)Delete characters
StringOp.Append(text)Append text
StringOp.Modify(index, length, text)Replace range

Sequence Operations​

OperationDescription
Patch.append(optic, elements)Append elements to end
Patch.insertAt(optic, index, elements)Insert at index
Patch.deleteAt(optic, index, count)Delete elements
Patch.modifyAt(optic, index, patch)Apply nested patch at index

Map Operations​

OperationDescription
Patch.addKey(optic, key, value)Add key-value pair
Patch.removeKey(optic, key)Remove key
Patch.modifyKey(optic, key, patch)Apply nested patch to value

Best Practices​

  1. Use increment for counters — Increment operations are commutative and merge-friendly
  2. Prefer fine-grained patches — Instead of replacing entire records, patch individual fields
  3. Check isEmpty before applying — Skip no-op patches for efficiency
  4. Use Strict mode for validation — Catch errors early in development
  5. Use Lenient mode for resilience — Handle partial updates gracefully in production
  6. Serialize patches for audit — Store the patch representation, not just before/after snapshots