@zio.dev/zio-blocks 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,631 @@
1
+ ---
2
+ id: patch
3
+ title: "Patching"
4
+ ---
5
+
6
+ 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.
7
+
8
+ ## Overview
9
+
10
+ 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:
11
+
12
+ - **Remote Patching** — Send patches over the network to update remote state without transmitting entire objects
13
+ - **Audit Logs** — Record patches as a log of changes for compliance, debugging, or undo functionality
14
+ - **CRDT-like Operations** — Use commutative operations like `increment` that can be safely applied in any order
15
+ - **Optimistic Updates** — Apply patches locally while syncing with a server
16
+ - **Schema Evolution** — Patches work with the schema system, adapting as data structures evolve
17
+
18
+ ```scala
19
+ import zio.blocks.schema._
20
+ import zio.blocks.schema.patch._
21
+
22
+ case class Person(name: String, age: Int)
23
+ object Person extends CompanionOptics[Person] {
24
+ implicit val schema: Schema[Person] = Schema.derived
25
+ val name: Lens[Person, String] = optic(_.name)
26
+ val age: Lens[Person, Int] = optic(_.age)
27
+ }
28
+
29
+ // Create patches using smart constructors
30
+ val patch1 = Patch.set(Person.name, "John")
31
+ val patch2 = Patch.increment(Person.age, 1)
32
+
33
+ // Compose patches
34
+ val combined = patch1 ++ patch2
35
+
36
+ // Apply the patch
37
+ val jane = Person("Jane", 25)
38
+ val result = combined(jane) // Person("John", 26)
39
+ ```
40
+
41
+ ## Creating Patches
42
+
43
+ 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.
44
+
45
+ ### Setting Values
46
+
47
+ The `set` operation replaces a value at the specified location:
48
+
49
+ ```scala
50
+ case class Address(street: String, city: String, zip: String)
51
+ object Address extends CompanionOptics[Address] {
52
+ implicit val schema: Schema[Address] = Schema.derived
53
+ val street: Lens[Address, String] = optic(_.street)
54
+ val city: Lens[Address, String] = optic(_.city)
55
+ }
56
+
57
+ case class Person(name: String, address: Address)
58
+ object Person extends CompanionOptics[Person] {
59
+ implicit val schema: Schema[Person] = Schema.derived
60
+ val name: Lens[Person, String] = optic(_.name)
61
+ val address: Lens[Person, Address] = optic(_.address)
62
+ val city: Lens[Person, String] = optic(_.address.city)
63
+ }
64
+
65
+ // Set a simple field
66
+ val setName = Patch.set(Person.name, "Alice")
67
+
68
+ // Set a nested field
69
+ val setCity = Patch.set(Person.city, "San Francisco")
70
+
71
+ // Set an entire nested record
72
+ val newAddress = Address("123 Main St", "NYC", "10001")
73
+ val setAddress = Patch.set(Person.address, newAddress)
74
+ ```
75
+
76
+ The `set` operation works with all optic types:
77
+ - **Lens** — Sets a single field
78
+ - **Optional** — Sets a value if the path exists
79
+ - **Traversal** — Sets all matching elements to the same value
80
+ - **Prism** — Sets a variant case
81
+
82
+ ### Numeric Increments
83
+
84
+ 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:
85
+
86
+ ```scala
87
+ case class Counter(count: Int, total: Long, balance: BigDecimal)
88
+ object Counter extends CompanionOptics[Counter] {
89
+ implicit val schema: Schema[Counter] = Schema.derived
90
+ val count: Lens[Counter, Int] = optic(_.count)
91
+ val total: Lens[Counter, Long] = optic(_.total)
92
+ val balance: Lens[Counter, BigDecimal] = optic(_.balance)
93
+ }
94
+
95
+ // Increment various numeric types
96
+ val addOne = Patch.increment(Counter.count, 1)
97
+ val addTen = Patch.increment(Counter.count, 10)
98
+ val subtractFive = Patch.increment(Counter.count, -5)
99
+
100
+ // Works with all numeric types
101
+ val addToTotal = Patch.increment(Counter.total, 1000L)
102
+ val addToBalance = Patch.increment(Counter.balance, BigDecimal("99.99"))
103
+
104
+ // Compose multiple increments
105
+ val combined = addOne ++ addTen ++ subtractFive
106
+ val counter = Counter(0, 0L, BigDecimal(0))
107
+ combined(counter) // Counter(6, 0, 0)
108
+ ```
109
+
110
+ Supported numeric types:
111
+ - `Int`, `Long`, `Short`, `Byte`
112
+ - `Float`, `Double`
113
+ - `BigInt`, `BigDecimal`
114
+
115
+ ### Temporal Operations
116
+
117
+ Patches support temporal arithmetic with `addDuration` and `addPeriod`:
118
+
119
+ ```scala
120
+ import java.time._
121
+
122
+ case class Event(
123
+ timestamp: Instant,
124
+ scheduledDate: LocalDate,
125
+ scheduledTime: LocalDateTime,
126
+ duration: Duration
127
+ )
128
+ object Event extends CompanionOptics[Event] {
129
+ implicit val schema: Schema[Event] = Schema.derived
130
+ val timestamp: Lens[Event, Instant] = optic(_.timestamp)
131
+ val scheduledDate: Lens[Event, LocalDate] = optic(_.scheduledDate)
132
+ val scheduledTime: Lens[Event, LocalDateTime] = optic(_.scheduledTime)
133
+ val duration: Lens[Event, Duration] = optic(_.duration)
134
+ }
135
+
136
+ // Add duration to an Instant
137
+ val postpone1Hour = Patch.addDuration(Event.timestamp, Duration.ofHours(1))
138
+
139
+ // Add period to a LocalDate
140
+ val postpone1Week = Patch.addPeriod(Event.scheduledDate, Period.ofWeeks(1))
141
+
142
+ // Add both period and duration to a LocalDateTime
143
+ val postpone = Patch.addPeriodAndDuration(
144
+ Event.scheduledTime,
145
+ Period.ofDays(1),
146
+ Duration.ofHours(2)
147
+ )
148
+
149
+ // Add duration to a Duration field
150
+ import Patch.DurationDummy.ForDuration
151
+ val extendDuration = Patch.addDuration(Event.duration, Duration.ofMinutes(30))
152
+ ```
153
+
154
+ ### String Edits
155
+
156
+ The `editString` operation applies character-level edits to strings:
157
+
158
+ ```scala
159
+ case class Document(content: String)
160
+ object Document extends CompanionOptics[Document] {
161
+ implicit val schema: Schema[Document] = Schema.derived
162
+ val content: Lens[Document, String] = optic(_.content)
163
+ }
164
+
165
+ // Insert text at position
166
+ val insertHello = Patch.editString(
167
+ Document.content,
168
+ Vector(Patch.StringOp.Insert(0, "Hello "))
169
+ )
170
+
171
+ // Delete characters
172
+ val deleteFirst5 = Patch.editString(
173
+ Document.content,
174
+ Vector(Patch.StringOp.Delete(0, 5))
175
+ )
176
+
177
+ // Append text
178
+ val appendBang = Patch.editString(
179
+ Document.content,
180
+ Vector(Patch.StringOp.Append("!"))
181
+ )
182
+
183
+ // Replace a range (delete then insert)
184
+ val replaceWorld = Patch.editString(
185
+ Document.content,
186
+ Vector(Patch.StringOp.Modify(6, 5, "Universe"))
187
+ )
188
+
189
+ // Combine multiple edits (applied in sequence)
190
+ val doc = Document("Hello World")
191
+ val edits = Patch.editString(
192
+ Document.content,
193
+ Vector(
194
+ Patch.StringOp.Delete(5, 6), // "Hello"
195
+ Patch.StringOp.Append(" there!") // "Hello there!"
196
+ )
197
+ )
198
+ edits(doc) // Document("Hello there!")
199
+ ```
200
+
201
+ String edit operations:
202
+ - `Insert(index, text)` — Insert text at the given position
203
+ - `Delete(index, length)` — Delete characters starting at index
204
+ - `Append(text)` — Append text to the end
205
+ - `Modify(index, length, text)` — Replace a range with new text
206
+
207
+ ## Working with Collections
208
+
209
+ ### Appending Elements
210
+
211
+ The `append` operation adds elements to the end of a sequence:
212
+
213
+ ```scala
214
+ case class TodoList(items: Vector[String])
215
+ object TodoList extends CompanionOptics[TodoList] {
216
+ implicit val schema: Schema[TodoList] = Schema.derived
217
+ val items: Lens[TodoList, Vector[String]] = optic(_.items)
218
+ }
219
+
220
+ import Patch.CollectionDummy.ForVector
221
+
222
+ val addItems = Patch.append(
223
+ TodoList.items,
224
+ Vector("Buy groceries", "Walk the dog")
225
+ )
226
+
227
+ val list = TodoList(Vector("Existing task"))
228
+ addItems(list) // TodoList(Vector("Existing task", "Buy groceries", "Walk the dog"))
229
+ ```
230
+
231
+ Works with `Vector`, `List`, `Seq`, `IndexedSeq`, and `LazyList`. Use the appropriate implicit:
232
+
233
+ ```scala
234
+ import Patch.CollectionDummy.ForList
235
+ import Patch.CollectionDummy.ForSeq
236
+ import Patch.CollectionDummy.ForIndexedSeq
237
+ import Patch.CollectionDummy.ForLazyList
238
+ ```
239
+
240
+ ### Inserting at Index
241
+
242
+ The `insertAt` operation inserts elements at a specific position:
243
+
244
+ ```scala
245
+ import Patch.CollectionDummy.ForVector
246
+
247
+ val insertAtStart = Patch.insertAt(
248
+ TodoList.items,
249
+ 0,
250
+ Vector("First priority")
251
+ )
252
+
253
+ val insertInMiddle = Patch.insertAt(
254
+ TodoList.items,
255
+ 1,
256
+ Vector("Second item", "Third item")
257
+ )
258
+
259
+ val list = TodoList(Vector("A", "B", "C"))
260
+ insertInMiddle(list) // TodoList(Vector("A", "Second item", "Third item", "B", "C"))
261
+ ```
262
+
263
+ ### Deleting Elements
264
+
265
+ The `deleteAt` operation removes elements starting at an index:
266
+
267
+ ```scala
268
+ import Patch.CollectionDummy.ForVector
269
+
270
+ // Delete one element at index 1
271
+ val deleteOne = Patch.deleteAt(TodoList.items, 1, 1)
272
+
273
+ // Delete three elements starting at index 0
274
+ val deleteThree = Patch.deleteAt(TodoList.items, 0, 3)
275
+
276
+ val list = TodoList(Vector("A", "B", "C", "D"))
277
+ deleteOne(list) // TodoList(Vector("A", "C", "D"))
278
+ ```
279
+
280
+ ### Modifying Elements
281
+
282
+ The `modifyAt` operation applies a nested patch to an element at a specific index:
283
+
284
+ ```scala
285
+ case class Task(title: String, priority: Int)
286
+ object Task extends CompanionOptics[Task] {
287
+ implicit val schema: Schema[Task] = Schema.derived
288
+ val title: Lens[Task, String] = optic(_.title)
289
+ val priority: Lens[Task, Int] = optic(_.priority)
290
+ }
291
+
292
+ case class Project(tasks: Vector[Task])
293
+ object Project extends CompanionOptics[Project] {
294
+ implicit val schema: Schema[Project] = Schema.derived
295
+ val tasks: Lens[Project, Vector[Task]] = optic(_.tasks)
296
+ }
297
+
298
+ import Patch.CollectionDummy.ForVector
299
+
300
+ // Create a patch for the nested Task type
301
+ val increasePriority = Patch.increment(Task.priority, 1)
302
+
303
+ // Modify the task at index 0
304
+ val modifyFirst = Patch.modifyAt(Project.tasks, 0, increasePriority)
305
+
306
+ val project = Project(Vector(
307
+ Task("Build feature", 1),
308
+ Task("Write tests", 2)
309
+ ))
310
+ modifyFirst(project)
311
+ // Project(Vector(Task("Build feature", 2), Task("Write tests", 2)))
312
+ ```
313
+
314
+ ## Working with Maps
315
+
316
+ ### Adding Keys
317
+
318
+ The `addKey` operation adds a new key-value pair to a map:
319
+
320
+ ```scala
321
+ case class Config(settings: Map[String, Int])
322
+ object Config extends CompanionOptics[Config] {
323
+ implicit val schema: Schema[Config] = Schema.derived
324
+ val settings: Lens[Config, Map[String, Int]] = optic(_.settings)
325
+ }
326
+
327
+ val addTimeout = Patch.addKey(Config.settings, "timeout", 30)
328
+ val addRetries = Patch.addKey(Config.settings, "retries", 3)
329
+
330
+ val config = Config(Map("port" -> 8080))
331
+ val combined = addTimeout ++ addRetries
332
+ combined(config) // Config(Map("port" -> 8080, "timeout" -> 30, "retries" -> 3))
333
+ ```
334
+
335
+ ### Removing Keys
336
+
337
+ The `removeKey` operation removes a key from a map:
338
+
339
+ ```scala
340
+ val removePort = Patch.removeKey(Config.settings, "port")
341
+
342
+ val config = Config(Map("port" -> 8080, "timeout" -> 30))
343
+ removePort(config) // Config(Map("timeout" -> 30))
344
+ ```
345
+
346
+ ### Modifying Values
347
+
348
+ The `modifyKey` operation applies a nested patch to the value at a specific key:
349
+
350
+ ```scala
351
+ case class UserSettings(preferences: Map[String, Int])
352
+ object UserSettings extends CompanionOptics[UserSettings] {
353
+ implicit val schema: Schema[UserSettings] = Schema.derived
354
+ val preferences: Lens[UserSettings, Map[String, Int]] = optic(_.preferences)
355
+ }
356
+
357
+ // Create a patch that increments an Int
358
+ val incrementValue: Patch[Int] = {
359
+ implicit val intSchema: Schema[Int] = Schema[Int]
360
+ Patch(
361
+ DynamicPatch.root(Patch.Operation.PrimitiveDelta(Patch.PrimitiveOp.IntDelta(10))),
362
+ intSchema
363
+ )
364
+ }
365
+
366
+ val increaseVolume = Patch.modifyKey(UserSettings.preferences, "volume", incrementValue)
367
+
368
+ val settings = UserSettings(Map("volume" -> 50, "brightness" -> 80))
369
+ increaseVolume(settings) // UserSettings(Map("volume" -> 60, "brightness" -> 80))
370
+ ```
371
+
372
+ ## Composing Patches
373
+
374
+ Patches compose with the `++` operator. The result applies the first patch, then the second:
375
+
376
+ ```scala
377
+ case class Account(name: String, balance: Int, active: Boolean)
378
+ object Account extends CompanionOptics[Account] {
379
+ implicit val schema: Schema[Account] = Schema.derived
380
+ val name: Lens[Account, String] = optic(_.name)
381
+ val balance: Lens[Account, Int] = optic(_.balance)
382
+ val active: Lens[Account, Boolean] = optic(_.active)
383
+ }
384
+
385
+ val rename = Patch.set(Account.name, "Premium Account")
386
+ val deposit = Patch.increment(Account.balance, 100)
387
+ val activate = Patch.set(Account.active, true)
388
+
389
+ // Compose all patches
390
+ val upgrade = rename ++ deposit ++ activate
391
+
392
+ val account = Account("Basic", 50, false)
393
+ upgrade(account) // Account("Premium Account", 150, true)
394
+ ```
395
+
396
+ Composition is associative: `(a ++ b) ++ c` equals `a ++ (b ++ c)`.
397
+
398
+ The empty patch acts as an identity element:
399
+
400
+ ```scala
401
+ val empty = Patch.empty[Account]
402
+ val patch = Patch.increment(Account.balance, 100)
403
+
404
+ (patch ++ empty)(account) == patch(account) // true
405
+ (empty ++ patch)(account) == patch(account) // true
406
+ ```
407
+
408
+ ## Applying Patches
409
+
410
+ ### Basic Application
411
+
412
+ The simplest way to apply a patch uses the `apply` method, which uses `Lenient` mode and returns the original value on failure:
413
+
414
+ ```scala
415
+ val patch = Patch.increment(Account.balance, 100)
416
+ val account = Account("Test", 50, true)
417
+
418
+ val result: Account = patch(account) // Account("Test", 150, true)
419
+ ```
420
+
421
+ ### Application Modes
422
+
423
+ For more control, use the overload that takes a `PatchMode`:
424
+
425
+ ```scala
426
+ val result: Either[SchemaError, Account] = patch(account, PatchMode.Strict)
427
+ ```
428
+
429
+ #### PatchMode.Strict
430
+
431
+ Fails immediately if any operation cannot be applied:
432
+
433
+ ```scala
434
+ case class Data(items: Vector[Int])
435
+ object Data extends CompanionOptics[Data] {
436
+ implicit val schema: Schema[Data] = Schema.derived
437
+ val items: Lens[Data, Vector[Int]] = optic(_.items)
438
+ }
439
+
440
+ import Patch.CollectionDummy.ForVector
441
+
442
+ // Try to delete at an invalid index
443
+ val badDelete = Patch.deleteAt(Data.items, 10, 1)
444
+ val data = Data(Vector(1, 2, 3))
445
+
446
+ badDelete(data, PatchMode.Strict)
447
+ // Left(SchemaError(...index out of bounds...))
448
+
449
+ badDelete(data, PatchMode.Lenient)
450
+ // Right(Data(Vector(1, 2, 3))) // Operation skipped
451
+ ```
452
+
453
+ Use `Strict` when you need to know if every operation succeeded.
454
+
455
+ #### PatchMode.Lenient
456
+
457
+ Skips operations that fail preconditions and continues with the rest:
458
+
459
+ ```scala
460
+ val patch1 = Patch.deleteAt(Data.items, 10, 1) // Will fail
461
+ val patch2 = Patch.increment(Counter.count, 5) // Will succeed
462
+
463
+ val combined = patch1 ++ patch2
464
+
465
+ combined(data, PatchMode.Lenient)
466
+ // Skips the invalid delete, applies the increment
467
+ ```
468
+
469
+ Use `Lenient` for best-effort patching where partial success is acceptable.
470
+
471
+ #### PatchMode.Clobber
472
+
473
+ Attempts to force operations through, using fallback behaviors:
474
+
475
+ ```scala
476
+ // Adding a key that already exists
477
+ val addExisting = Patch.addKey(Config.settings, "port", 9000)
478
+ val config = Config(Map("port" -> 8080))
479
+
480
+ addExisting(config, PatchMode.Strict)
481
+ // Left(SchemaError(...key already exists...))
482
+
483
+ addExisting(config, PatchMode.Clobber)
484
+ // Right(Config(Map("port" -> 9000))) // Overwrites existing key
485
+ ```
486
+
487
+ Use `Clobber` when you want to force updates regardless of preconditions.
488
+
489
+ ### Option-Based Application
490
+
491
+ Use `applyOption` for a simpler API that returns `None` on any failure:
492
+
493
+ ```scala
494
+ val patch = Patch.increment(Account.balance, 100)
495
+ val result: Option[Account] = patch.applyOption(account)
496
+ // Some(Account("Test", 150, true))
497
+ ```
498
+
499
+ ## Diffing Values
500
+
501
+ The `diff` method computes a minimal patch that transforms one value into another:
502
+
503
+ ```scala
504
+ val old = Person("Alice", 25)
505
+ val new = Person("Alice", 26)
506
+
507
+ // Compute the difference
508
+ val dynamicOld = Schema[Person].toDynamicValue(old)
509
+ val dynamicNew = Schema[Person].toDynamicValue(new)
510
+ val patch = dynamicOld.diff(dynamicNew)
511
+
512
+ // Apply to transform old into new
513
+ patch(dynamicOld, PatchMode.Strict)
514
+ // Right(dynamicNew)
515
+ ```
516
+
517
+ The differ produces minimal patches using type-appropriate operations:
518
+ - **Numeric fields** — Uses delta operations (`+1` instead of `set 26`)
519
+ - **Strings** — Uses edit operations when more compact than replacement
520
+ - **Records** — Only includes changed fields
521
+ - **Sequences** — Uses LCS algorithm to compute minimal insert/delete operations
522
+ - **Maps** — Produces add/remove/modify operations for changed entries
523
+
524
+ ### Diff Example
525
+
526
+ ```scala
527
+ case class User(
528
+ name: String,
529
+ scores: Vector[Int],
530
+ metadata: Map[String, String]
531
+ )
532
+ object User {
533
+ implicit val schema: Schema[User] = Schema.derived
534
+ }
535
+
536
+ val v1 = User(
537
+ "Alice",
538
+ Vector(10, 20, 30),
539
+ Map("level" -> "1", "status" -> "active")
540
+ )
541
+
542
+ val v2 = User(
543
+ "Alice",
544
+ Vector(10, 25, 30, 40),
545
+ Map("level" -> "2", "status" -> "active")
546
+ )
547
+
548
+ val d1 = Schema[User].toDynamicValue(v1)
549
+ val d2 = Schema[User].toDynamicValue(v2)
550
+ val patch = d1.diff(d2)
551
+
552
+ // The patch contains:
553
+ // - scores: delete at index 1, insert 25 at index 1, append 40
554
+ // - metadata["level"]: increment from "1" to "2" (or set if strings)
555
+ ```
556
+
557
+ ## Serializable Operations
558
+
559
+ All patch operations are serializable through the schema system. Each operation type has an implicit `Schema` instance:
560
+
561
+ ```scala
562
+ // Patches can be converted to/from DynamicValue
563
+ val patch = Patch.increment(Account.balance, 100)
564
+ val dynamicPatch = Schema[DynamicPatch].toDynamicValue(patch.dynamicPatch)
565
+
566
+ // Serialize to JSON, Avro, MessagePack, etc.
567
+ import zio.blocks.schema.json._
568
+ val json = JsonEncoder.encode(patch.dynamicPatch)
569
+ ```
570
+
571
+ This enables storing patches in databases, sending them over APIs, or logging them for audit purposes.
572
+
573
+ ## Operation Reference
574
+
575
+ ### Value Operations
576
+
577
+ | Operation | Description |
578
+ |-----------|-------------|
579
+ | `Patch.set(optic, value)` | Set a value at the optic path |
580
+ | `Patch.replace(optic, value)` | Alias for `set` |
581
+ | `Patch.empty[S]` | Empty patch (identity) |
582
+
583
+ ### Numeric Operations
584
+
585
+ | Operation | Description |
586
+ |-----------|-------------|
587
+ | `Patch.increment(optic, delta)` | Add delta to numeric field |
588
+
589
+ ### Temporal Operations
590
+
591
+ | Operation | Description |
592
+ |-----------|-------------|
593
+ | `Patch.addDuration(optic, duration)` | Add duration to `Instant` or `Duration` |
594
+ | `Patch.addPeriod(optic, period)` | Add period to `LocalDate` or `Period` |
595
+ | `Patch.addPeriodAndDuration(optic, period, duration)` | Add both to `LocalDateTime` |
596
+
597
+ ### String Operations
598
+
599
+ | Operation | Description |
600
+ |-----------|-------------|
601
+ | `Patch.editString(optic, edits)` | Apply string edit operations |
602
+ | `StringOp.Insert(index, text)` | Insert text at position |
603
+ | `StringOp.Delete(index, length)` | Delete characters |
604
+ | `StringOp.Append(text)` | Append text |
605
+ | `StringOp.Modify(index, length, text)` | Replace range |
606
+
607
+ ### Sequence Operations
608
+
609
+ | Operation | Description |
610
+ |-----------|-------------|
611
+ | `Patch.append(optic, elements)` | Append elements to end |
612
+ | `Patch.insertAt(optic, index, elements)` | Insert at index |
613
+ | `Patch.deleteAt(optic, index, count)` | Delete elements |
614
+ | `Patch.modifyAt(optic, index, patch)` | Apply nested patch at index |
615
+
616
+ ### Map Operations
617
+
618
+ | Operation | Description |
619
+ |-----------|-------------|
620
+ | `Patch.addKey(optic, key, value)` | Add key-value pair |
621
+ | `Patch.removeKey(optic, key)` | Remove key |
622
+ | `Patch.modifyKey(optic, key, patch)` | Apply nested patch to value |
623
+
624
+ ## Best Practices
625
+
626
+ 1. **Use increment for counters** — Increment operations are commutative and merge-friendly
627
+ 2. **Prefer fine-grained patches** — Instead of replacing entire records, patch individual fields
628
+ 3. **Check isEmpty before applying** — Skip no-op patches for efficiency
629
+ 4. **Use Strict mode for validation** — Catch errors early in development
630
+ 5. **Use Lenient mode for resilience** — Handle partial updates gracefully in production
631
+ 6. **Serialize patches for audit** — Store the patch representation, not just before/after snapshots