@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,1613 @@
1
+ ---
2
+ id: optics
3
+ title: "Optics"
4
+ ---
5
+
6
+ Optics are a fundamental feature of ZIO Blocks that enable type-safe, composable access and modification of nested data structures. What sets ZIO Blocks apart is its implementation of **reflective optics** — a novel construct that combines the operational capabilities of traditional optics with embedded structural metadata, enabling both data manipulation AND introspection.
7
+
8
+ ## What Are Optics?
9
+
10
+ Optics are abstractions that allow you to focus on a specific part of a data structure. They provide a way to **view**, **update**, and **traverse** nested fields in immutable data types without boilerplate code:
11
+
12
+
13
+ Every optic has two type parameters:
14
+
15
+ ```
16
+ Optic[S, A]
17
+ │ │
18
+ │ └── Focus: The "little thing" being accessed
19
+ └───── Source: The "big thing" containing the focus
20
+ ```
21
+
22
+ The `S` is the source type from which data is accessed or modified. The `A` is the focus type or the target type of the optic.
23
+
24
+ The terminology comes from physical optics — like using a magnifying glass to focus on a small part of something larger.
25
+
26
+ ## Reflective Optics
27
+
28
+ [//]: # (Optics are **reified fields, cases, and for-loops**.)
29
+
30
+ Traditional optics are defined purely by functions — they give you **capabilities** (get/set) but no **knowledge** about the structure being accessed. ZIO Blocks introduces **reflective optics** which embed the structure of both the source and focus as first-class reified schemas:
31
+
32
+ ```
33
+ sealed trait Optic[S, A] {
34
+ def source: Reflect.Bound[S]
35
+ def focus: Reflect.Bound[A]
36
+
37
+ // Other common operations
38
+ }
39
+ ```
40
+
41
+ The `source` and `focus` methods return `Reflect.Bound` instances that describe the schema of the source and focus types, respectively. This means that every optic carries with it detailed information about the data shapes it operates on.
42
+
43
+ This enables capabilities that ordinary optics have never had before:
44
+
45
+ - **Amazing Error Messages** — Know exactly where and why an optic operation failed, because you know which part of the structure was being accessed during the failure
46
+ - **DSL Integration** — Optics as first-class values enables us to write query DSLs that can introspect the data model being queried. So we can integrate that DSL with underlying storage systems (SQL, NoSQL, etc.) without losing type safety.
47
+
48
+ The `OpticCheck` data type is an error reporting mechanism for reflective optics. It captures detailed diagnostic information when optic operations fail, solving a long-standing pain point in traditional optics libraries where failures silently return `None` without explanation. It provides detailed context about where and why the replacement failed. It includes the expected and actual cases, the full optic path, and the actual value encountered.
49
+
50
+ ## The Optic Type Hierarchy
51
+
52
+ ZIO Blocks provides four primary optic types, each designed for a specific data shape:
53
+
54
+ 1. **`Lens[S, A]`** — Focuses on a single field within a record (case class)
55
+ 2. **`Prism[S, A <: S]`** — Focuses on a specific case within a sum type (sealed trait/enum)
56
+ 3. **`Optional[S, A]`** — It behaves like a combination of `Lens` and `Prism`; focuses on a value that may or may not exist. Like a `Lens`, it can focus on a part of a larger structure and (if present) get and set it. Like a `Prism`, the focus may or may not exist.
57
+ 4. **`Traversal[S, A]`** — Focuses on zero or more elements within a collection (List, Vector, Set, Map, etc.)
58
+
59
+ ```text
60
+ ┌─────────────────┐
61
+ │ Optic[S, A] │
62
+ │ (abstract) │
63
+ └────────┬────────┘
64
+
65
+ ┌───────────────────┬───────────────────┬───────────────────┐
66
+ │ │ │ │
67
+ ▼ ▼ ▼ ▼
68
+ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
69
+ │ Lens[S, A] │ │ Prism[S, A] │ │ Optional[S, A] │ │Traversal[S, A] │
70
+ │ │ │ (A <: S) │ │ │ │ │
71
+ │ Fields in │ │ Cases in │ │ Combinations of │ │ Elements in │
72
+ │ Records │ │ Enums/Variants │ │ Lens + Prism │ │ Collections │
73
+ └─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘
74
+ ```
75
+
76
+ ## Lens
77
+
78
+ A **Lens** focuses on a single field within a record (case class). It always succeeds because records always have all their fields.
79
+
80
+ Lens has two primary operations:
81
+
82
+ | Method | Signature | Description |
83
+ |-----------|---------------|-----------------------------------------|
84
+ | `get` | `S => A` | Extract the field value |
85
+ | `replace` | `(S, A) => S` | Create new structure with updated field |
86
+
87
+ ```scala
88
+ sealed trait Lens[S, A] extends Optic[S, A] {
89
+ def get(s: S): A
90
+ def replace(s: S, a: A): S
91
+ }
92
+ ```
93
+
94
+ Once you have a lens, you can use it to extract or replace the focused field immutably. There are two main approaches to creating lenses: manual construction and automatic macro-based derivation. Let's explore each.
95
+
96
+ ### Manual Lens Construction
97
+
98
+ To create a lens manually for a field, you can use the following constructor:
99
+
100
+ ```scala
101
+ object Lens {
102
+ def apply[S, A](
103
+ source : Reflect.Record.Bound[S],
104
+ focusTerm: Term.Bound[S, A]
105
+ ): Lens[S, A] = ???
106
+ }
107
+ ```
108
+
109
+ It takes a `Reflect.Record.Bound[S]` representing the schema of the source type `S`, and a `Term.Bound[S, A]` representing the specific field within `S` that the lens will focus on, and finally returns a lens of type from `S` to `A`.
110
+
111
+ Assume you have an `Address` case class like this with the schema derived:
112
+
113
+ ```scala
114
+ case class Address(street: String, city: String, zipCode: String)
115
+ object Address {
116
+ implicit val schema: Schema[Address] = Schema.derived[Address]
117
+ }
118
+ ```
119
+
120
+ You can create a lens to focus on the `street` field like this:
121
+
122
+ ```scala
123
+ object Address {
124
+ implicit val schema: Schema[Address] = Schema.derived[Address]
125
+
126
+ val street: Lens[Address, String] =
127
+ Lens[Address, String](
128
+ Schema[Address].reflect.asRecord.get,
129
+ Schema[String].reflect.asTerm("street")
130
+ )
131
+ }
132
+ ```
133
+
134
+ Now you can use the lens to get or replace the `street` field:
135
+
136
+ ```scala
137
+ val address = Address("123 Main St", "Springfield", "12345")
138
+ val streetName: String = Address.street.get(address) // "123 Main St"
139
+ val updatedAddress: Address = Address.street.replace(address, "456 Elm St")
140
+ // Address("456 Elm St", "Springfield", "12345")
141
+ ```
142
+
143
+ ### Automatic Macro-Based Lens Derivation
144
+
145
+ While manual lens construction gives you fine-grained control, ZIO Blocks provides macro-based derivation as the **preferred approach** for creating lenses.
146
+
147
+ The `optic` macro inside the `CompanionOptics` trait creates a lens using intuitive selector syntax that mirrors standard Scala field access:
148
+
149
+ ```scala
150
+ import zio.blocks.schema.optic
151
+
152
+ case class Person(name: String, age: Int)
153
+
154
+ object Person extends CompanionOptics[Person] {
155
+ implicit val schema: Schema[Person] = Schema.derived
156
+
157
+ val name: Lens[Person, String] = optic(_.name)
158
+ val age : Lens[Person, Int ] = optic(_.age)
159
+ }
160
+ ```
161
+
162
+ The macro inspects the selector expression `_.name`, validates that it corresponds to a valid field path, and generates the appropriate lens. This approach is type-safe—the compiler verifies that the field exists and has the correct type.
163
+
164
+ For nested structures, the `optic` macro can create composed lenses by chaining field accesses:
165
+
166
+ ```scala
167
+ case class Address(street: String, city: String)
168
+ case class Person(name: String, address: Address)
169
+
170
+ object Person {
171
+ implicit val schema: Schema[Person] = Schema.derived[Person]
172
+
173
+ // Lens directly to the nested street field
174
+ val street: Lens[Person, String] = optic(_.address.street)
175
+ }
176
+ ```
177
+
178
+ Now you can use the `street` lens to access the nested `street` field directly and update it:
179
+
180
+ ```scala
181
+ val person = Person("John", Address("123 Main St", "Springfield"))
182
+ val street = Person.street.get(person)
183
+ val updatedPerson = Person.street.replace(person, "456 Elm St")
184
+ // => Person("John", Address("456 Elm St", "Springfield"))
185
+ ```
186
+
187
+ ## Prism
188
+
189
+ A **`Prism[S, A <: S]`** focuses on a specific case within a sum type (sealed trait/enum). It may fail if the value is a different case.
190
+
191
+ The key operations of a prism are summarized in the table below:
192
+
193
+ | Method | Signature | Description |
194
+ |-----------------|-----------------------------------|----------------------------------------------------------------|
195
+ | `getOption` | `(S) => Option[A]` | Extracts if value matches subtype `A`, otherwise `None` |
196
+ | `getOrFail` | `(S) => Either[OpticCheck, A]` | Extracts with detailed error info on type mismatch |
197
+ | `reverseGet` | `(A) => S` | Upcasts subtype `A` back to supertype `S` |
198
+ | `replace` | `(S, A) => S` | Replaces if value matches subtype, otherwise returns unchanged |
199
+ | `replaceOption` | `(S, A) => Option[S]` | Replaces if value matches subtype, otherwise `None` |
200
+ | `replaceOrFail` | `(S, A) => Either[OpticCheck, S]` | Replaces with detailed error info on type mismatch |
201
+
202
+ Before discussing these operations, let's explore how we can create prisms. Similar to lenses, we have two approaches: manual construction and automatic macro-based derivation.
203
+
204
+ ### Manual Prism Construction
205
+
206
+ To create a prism for a case, you can use the following constructor:
207
+
208
+ ```scala
209
+ object Prism {
210
+ def apply[S, A <: S](
211
+ source : Reflect.Variant.Bound[S],
212
+ focusTerm: Term.Bound[S, A]
213
+ ): Prism[S, A] = ???
214
+ }
215
+ ```
216
+
217
+ It takes a `Reflect.Variant.Bound[S]` representing the schema of the sum type `S`, and a `Term.Bound[S, A]` representing the specific case within `S` that the prism will focus on. The result is a prism from `S` to `A`.
218
+
219
+ Assume you have a `Notification` sealed trait representing different notification types:
220
+
221
+ ```scala mdoc:compile-only
222
+ import zio.blocks.schema._
223
+
224
+ sealed trait Notification
225
+
226
+ object Notification {
227
+ case class Email(to: String, subject: String, body: String) extends Notification
228
+ case class SMS(phoneNumber: String, message: String) extends Notification
229
+ case class Push(deviceId: String, title: String, payload: Map[String, String]) extends Notification
230
+
231
+ implicit val schema: Schema[Notification] = Schema.derived
232
+ }
233
+ ```
234
+
235
+ First, we need to define schemas for each case class and then write a prism for each case (here we define a prism for the `Email` case):
236
+
237
+ ```scala mdoc:silent
238
+ import zio.blocks.schema._
239
+
240
+ sealed trait Notification
241
+
242
+ object Notification {
243
+ case class Email(to: String, subject: String, body: String) extends Notification
244
+ object Email {
245
+ implicit val schema: Schema[Email] = Schema.derived
246
+ }
247
+
248
+ case class SMS(phoneNumber: String, message: String) extends Notification
249
+ object SMS {
250
+ implicit val schema: Schema[SMS] = Schema.derived
251
+ }
252
+
253
+ case class Push(deviceId: String, title: String, payload: Map[String, String]) extends Notification
254
+ object Push {
255
+ implicit val schema: Schema[Push] = Schema.derived
256
+ }
257
+
258
+ implicit val schema: Schema[Notification] = Schema.derived
259
+
260
+ val email: Prism[Notification, Email] =
261
+ Prism[Notification, Email](
262
+ Schema[Notification].reflect.asVariant.get,
263
+ Schema[Email].reflect.asTerm("Email")
264
+ )
265
+
266
+ val sms: Prism[Notification, SMS] =
267
+ Prism[Notification, SMS](
268
+ Schema[Notification].reflect.asVariant.get,
269
+ Schema[SMS].reflect.asTerm("SMS")
270
+ )
271
+
272
+ val push: Prism[Notification, Push] =
273
+ Prism[Notification, Push](
274
+ Schema[Notification].reflect.asVariant.get,
275
+ Schema[Push].reflect.asTerm("Push")
276
+ )
277
+ }
278
+ ```
279
+
280
+ ### Automatic Macro-Based Prism Derivation
281
+
282
+ The `optic` macro inside the `CompanionOptics` trait creates a prism using intuitive selector syntax with the `when[CaseType]` method:
283
+
284
+ ```scala mdoc:compile-only
285
+ import zio.blocks.schema._
286
+
287
+ sealed trait Notification
288
+
289
+ object Notification extends CompanionOptics[Notification] {
290
+ case class Email(to: String, subject: String, body: String) extends Notification
291
+ case class SMS(phoneNumber: String, message: String) extends Notification
292
+ case class Push(deviceId: String, title: String, payload: Map[String, String]) extends Notification
293
+
294
+ implicit val schema: Schema[Notification] = Schema.derived
295
+
296
+ // Macro-derived prisms using when[CaseType] syntax
297
+ val email: Prism[Notification, Email] = optic(_.when[Email])
298
+ val sms : Prism[Notification, SMS] = optic(_.when[SMS])
299
+ val push : Prism[Notification, Push] = optic(_.when[Push])
300
+ }
301
+ ```
302
+
303
+ The macro inspects the selector expression `_.when[Email]`, validates that `Email` is a valid case of the sum type `Notification`, and generates the appropriate prism. This approach is type-safe—the compiler verifies that the case type exists and is a subtype of the sum type.
304
+
305
+ For nested structures, the `optic` macro can compose prisms with lenses by chaining case selection with field access:
306
+
307
+ ```scala mdoc:silent
308
+ sealed trait Response
309
+ object Response extends CompanionOptics[Response] {
310
+ case class Success(data: Data) extends Response
311
+ case class Failure(error: String) extends Response
312
+
313
+ case class Data(id: Int, value: String)
314
+
315
+ implicit val schema: Schema[Response] = Schema.derived
316
+
317
+ // Prism to the Success case
318
+ val success: Prism[Response, Success] = optic(_.when[Success])
319
+
320
+ // Composed optic: Prism + Lens = Optional
321
+ // Focus on the 'value' field inside the Success case
322
+ val successValue: Optional[Response, String] = optic(_.when[Success].data.value)
323
+ }
324
+ ```
325
+
326
+ When you compose a prism with a lens using the `optic` macro, the result is an `Optional`—an optic that may fail to focus (because the sum type might be a different case) but if it succeeds, always finds the field:
327
+
328
+ ```scala mdoc:compile-only
329
+ val response = Response.Success(Response.Data(1, "hello"))
330
+
331
+ Response.successValue.getOption(response)
332
+ // => Some("hello")
333
+
334
+ Response.successValue.getOption(Response.Failure("error"))
335
+ // => None (response is Failure, not Success)
336
+ ```
337
+
338
+ ### Operations
339
+
340
+ Now let's explore prism operations. Assume we have some sample notifications:
341
+
342
+ ```scala mdoc
343
+ // Sample notifications
344
+ val emailNotif: Notification = Notification.Email("user@example.com", "Hello", "Welcome!")
345
+ val smsNotif: Notification = Notification.SMS("+1234567890", "Your code is 1234")
346
+ val pushNotif: Notification = Notification.Push("device-abc", "Alert", Map("action" -> "open"))
347
+ ```
348
+
349
+ 1. **`Prism#getOption`** — Extract the case if it matches, otherwise None
350
+
351
+ ```scala
352
+ // getOption: Extract the case if it matches, otherwise None
353
+ Notification.email.getOption(emailNotif)
354
+ // => Some(Email(to = "user@example.com", subject = "Hello", body = "Welcome!"))
355
+
356
+ Notification.email.getOption(smsNotif)
357
+ // => None (smsNotif is an SMS, not an Email)
358
+
359
+ Notification.sms.getOption(smsNotif)
360
+ // Option[SMS] = Some(SMS(phoneNumber = "+1234567890", message = "Your code is 1234"))
361
+ ```
362
+
363
+ 2. **`Prism#getOrFail`** — Extract with detailed error information on failure:
364
+
365
+ ```text
366
+ trait Prism[S, A <: S] {
367
+ def getOrFail(s: S): Either[OpticCheck, A]
368
+ }
369
+ ```
370
+
371
+ It returns `Right(a)` if the source `s` matches the expected case `A`, or `Left(opticCheck)` containing detailed error information if it does not:
372
+
373
+ ```scala
374
+ Notification.email.getOrFail(emailNotif)
375
+ // => Right(Email(to = "user@example.com", subject = "Hello", body = "Welcome!"))
376
+
377
+ Notification.email.getOrFail(pushNotif)
378
+ // res5: Either[OpticCheck, Email] = Left(
379
+ // OpticCheck(
380
+ // List(
381
+ // UnexpectedCase(
382
+ // expectedCase = "Email",
383
+ // actualCase = "Push",
384
+ // full = DynamicOptic(ArraySeq(Case("Email"))),
385
+ // prefix = DynamicOptic(ArraySeq(Case("Email"))),
386
+ // actualValue = Push(
387
+ // deviceId = "device-abc",
388
+ // title = "Alert",
389
+ // payload = Map("action" -> "open")
390
+ // )
391
+ // )
392
+ // )
393
+ // )
394
+ // )
395
+ ```
396
+
397
+ 3. **`Prism#reverseGet`** — Upcast a specific case back to the parent sum type:
398
+
399
+ ```text
400
+ trait Prism[S, A <: S] {
401
+ def reverseGet(a: A): S
402
+ }
403
+ ```
404
+
405
+ An example of upcasting an `Email` back to `Notification`:
406
+
407
+ ```scala
408
+ val email = Notification.Email("alice@example.com", "Meeting", "See you at 3pm")
409
+ val notification: Notification = Notification.email.reverseGet(email)
410
+ // => Email("alice@example.com", "Meeting", "See you at 3pm") as Notification
411
+ ```
412
+
413
+ 4. **`Prism#replace`** — Replace the value if it matches the case:
414
+
415
+ ```scala
416
+ trait Prism[S, A <: S] {
417
+ def replace(s: S, a: A): S
418
+ }
419
+ ```
420
+
421
+ In the following example, we are replacing an existing `Email` notification with a new one:
422
+
423
+ ```scala
424
+ val newEmail = Notification.Email("new@example.com", "Updated", "New content")
425
+
426
+ Notification.email.replace(emailNotif, newEmail)
427
+ // => Email("new@example.com", "Updated", "New content")
428
+ ```
429
+
430
+ If the original value is NOT the expected case, replace returns unchanged:
431
+ ```scala
432
+ Notification.email.replace(smsNotif, newEmail)
433
+ // res7: Notification = SMS(
434
+ // phoneNumber = "+1234567890",
435
+ // message = "Your code is 1234"
436
+ // )
437
+ // => SMS("+1234567890", "Your code is 1234") (unchanged, it was an SMS)
438
+ ```
439
+
440
+ 5. **`Prism#replaceOption`** — Replace returning Some on success, None on mismatch:
441
+
442
+ ```scala
443
+ trait Prism[S, A <: S] {
444
+ def replaceOption(s: S, a: A): Option[S]
445
+ }
446
+ ```
447
+
448
+ The following example shows replacing an `Email` notification with a new one, returning `Some` on success and `None` if the original value is not an `Email`:
449
+
450
+ ```scala
451
+ Notification.email.replaceOption(emailNotif, newEmail)
452
+ // => Some(Email("new@example.com", "Updated", "New content"))
453
+
454
+ Notification.email.replaceOption(smsNotif, newEmail)
455
+ // => None (cannot replace, smsNotif is not an Email)
456
+ ```
457
+
458
+ 6. **`Prism#replaceOrFail`** — Replace with detailed error on mismatch:
459
+
460
+ ```scala
461
+ trait Prism[S, A <: S] {
462
+ def replaceOrFail(s: S, a: A): Either[OpticCheck, S]
463
+ }
464
+ ```
465
+
466
+ The following example shows replacing an `Email` notification with a new one, returning `Right(newValue)` on success and `Left(opticCheck)` with detailed error information if the original value is not an `Email`:
467
+
468
+ ```scala
469
+ Notification.email.replaceOrFail(emailNotif, newEmail)
470
+ // => Right(Email("new@example.com", "Updated", "New content"))
471
+
472
+ Notification.email.replaceOrFail(pushNotif, newEmail)
473
+ // res11: Either[OpticCheck, Notification] = Left(
474
+ // OpticCheck(
475
+ // List(
476
+ // UnexpectedCase(
477
+ // expectedCase = "Email",
478
+ // actualCase = "Push",
479
+ // full = DynamicOptic(ArraySeq(Case("Email"))),
480
+ // prefix = DynamicOptic(ArraySeq(Case("Email"))),
481
+ // actualValue = Push(
482
+ // deviceId = "device-abc",
483
+ // title = "Alert",
484
+ // payload = Map("action" -> "open")
485
+ // )
486
+ // )
487
+ // )
488
+ // )
489
+ // )
490
+ ```
491
+
492
+ ## Optional
493
+
494
+ You can think of **`Optional[S, A]`** as the composition of lenses and prisms — it focuses on a value that may or may not exist. Used for accessing fields through variant types, optional fields, or elements at specific indices.
495
+
496
+ ```scala
497
+ sealed trait Optional[S, A] extends Optic[S, A] {
498
+ def getOption(s: S): Option[A]
499
+ def getOrFail(s: S): Either[OpticCheck, A]
500
+ def replace(s: S, a: A): S
501
+ def replaceOption(s: S, a: A): Option[S]
502
+ def replaceOrFail(s: S, a: A): Either[OpticCheck, S]
503
+ }
504
+ ```
505
+
506
+ | Method | Signature | Description |
507
+ |-----------------|-----------------------------------|--------------------------------------------------------------|
508
+ | `getOption` | `(S) => Option[A]` | Extracts the focused value if accessible, otherwise `None` |
509
+ | `getOrFail` | `(S) => Either[OpticCheck, A]` | Extracts with detailed error info on failure |
510
+ | `replace` | `(S, A) => S` | Replaces if accessible, otherwise returns original unchanged |
511
+ | `replaceOption` | `(S, A) => Option[S]` | Replaces if accessible, otherwise `None` |
512
+ | `replaceOrFail` | `(S, A) => Either[OpticCheck, S]` | Replaces with detailed error info on failure |
513
+
514
+ ### Manual Optional Construction
515
+
516
+ Unlike `Lens` and `Prism` which have direct constructors, `Optional` is primarily constructed through **composition** of other optics, mainly from `Lens` and `Prism`:
517
+
518
+ ```scala
519
+ object Optional {
520
+ // Compose Lens with Prism (yields Optional)
521
+ def apply[S, T, A <: T](first: Lens[S, T], second: Prism[T, A]): Optional[S, A]
522
+
523
+ // Compose Prism with Lens (yields Optional)
524
+ def apply[S, T <: S, A](first: Prism[S, T], second: Lens[T, A]): Optional[S, A]
525
+ }
526
+ ```
527
+
528
+ | First | Second | Output |
529
+ |---------------|---------------|------------------|
530
+ | `Lens[S, T]` | `Prism[T, A]` | `Optional[S, A]` |
531
+ | `Prism[S, T]` | `Lens[T, A]` | `Optional[S, A]` |
532
+
533
+
534
+ These two basic compositions allow you to build `Optional` optics by combining lenses and prisms in either order.
535
+
536
+ In addition, `Optional` can also be composed with other optics to create more complex optionals, all these composition methods are supported via the `Optional.apply` method overloads:
537
+
538
+ | First | Second | Output |
539
+ |--------------------|--------------------|------------------|
540
+ | `Lens[S, T]` | `Optional[T, A]` | `Optional[S, A]` |
541
+ | `Optional[S, T]` | `Lens[T, A]` | `Optional[S, A]` |
542
+ | ------------------ | ------------------ | ---------------- |
543
+ | `Prism[S, T]` | `Optional[T, A]` | `Optional[S, A]` |
544
+ | `Optional[S, T]` | `Prism[T, A]` | `Optional[S, A]` |
545
+ | -------------- | --------------- | ---------------- |
546
+ | `Optional[S, T]` | `Optional[T, A]` | `Optional[S, A]` |
547
+
548
+ Beside the above composition methods, `Optional` provides specialized constructors for common patterns such as index-based access in sequences, key-based access in maps, and accessing wrapped types:
549
+
550
+ | Constructor | Signature | Description |
551
+ |--------------------|------------------------------------------------------------|-----------------------------------------|
552
+ | `Optional.at` | `(Reflect.Sequence.Bound[A, C], Int) => Optional[C[A], A]` | Accesses element at index in a sequence |
553
+ | `Optional.atKey` | `(Reflect.Map.Bound[K, V, M], K) => Optional[M[K, V], V]` | Accesses value at key in a map |
554
+ | `Optional.wrapped` | `(Reflect.Wrapper.Bound[A, B]) => Optional[A, B]` | Accesses inner value of a wrapper type |
555
+
556
+ #### Example: Composing Lens and Prism
557
+
558
+ The most common way to manually construct an `Optional` is by composing a `Lens` with a `Prism`. Assume you have a `PaymentMethod` sum type which has multiple cases, and we have written a prism for one of its cases, e.g., `CreditCard`:
559
+
560
+ ```scala mdoc:silent
561
+ import zio.blocks.schema._
562
+
563
+ sealed trait PaymentMethod
564
+ object PaymentMethod extends CompanionOptics[PaymentMethod] {
565
+ case class CreditCard(number: String, expiry: String) extends PaymentMethod
566
+ case class Cryptocurrency(walletAddress: String) extends PaymentMethod
567
+
568
+ implicit val schema: Schema[PaymentMethod] = Schema.derived
569
+
570
+ val creditCard : Prism[PaymentMethod, CreditCard] = optic(_.when[CreditCard])
571
+ val cryptocurrency : Prism[PaymentMethod, Cryptocurrency] = optic(_.when[Cryptocurrency])
572
+ }
573
+ ```
574
+
575
+ And also assume we have a `Customer` record that has a `payment` field of type `PaymentMethod` and we have written a lens for that field:
576
+
577
+ ```scala mdoc:silent
578
+ case class Customer(name: String, payment: PaymentMethod)
579
+ object Customer extends CompanionOptics[Customer] {
580
+ implicit val schema: Schema[Customer] = Schema.derived
581
+
582
+ val name : Lens[Customer, String ] = optic(_.name)
583
+ val payment: Lens[Customer, PaymentMethod] = optic(_.payment)
584
+ }
585
+ ```
586
+
587
+ We can now compose the `payment` lens with the `creditCard` prism to create an `Optional` that focuses on the `CreditCard` details within a `Customer`:
588
+
589
+ ```scala mdoc:compile-only
590
+ // Compose lens and prism manually to get Optional
591
+ val creditCard: Optional[Customer, PaymentMethod.CreditCard] =
592
+ Optional(Customer.payment, PaymentMethod.creditCard)
593
+ ```
594
+
595
+ #### Example: Index-Based Access
596
+
597
+ For accessing elements at specific indices in sequences:
598
+
599
+ ```scala mdoc:compile-only
600
+ import zio.blocks.schema._
601
+
602
+ case class Order(id: String, items: List[String])
603
+ object Order extends CompanionOptics[Order] {
604
+ implicit val schema: Schema[Order] = Schema.derived[Order]
605
+
606
+ val items: Lens[Order, List[String]] = optic(_.items)
607
+
608
+ // Manual Optional for first item
609
+ val firstItem: Optional[Order, String] = {
610
+ val atFirst = Optional.at(
611
+ Schema[List[String]].reflect.asSequence.get,
612
+ index = 0
613
+ )
614
+ Optional(items, atFirst)
615
+ }
616
+ }
617
+ ```
618
+
619
+ ### Automatic Macro-Based Optional Derivation
620
+
621
+ The `optic` macro inside the `CompanionOptics` trait creates optionals automatically through several syntactic patterns. The macro intelligently determines when an `Optional` is needed based on the path expression.
622
+
623
+ #### Accessing Inner Values of ADTs
624
+
625
+ By combining the `.when[Case]` (prism) and `.field-name` (lens) syntax of optic macro, you can create optionals that focus on the inner values of ADTs:
626
+
627
+ ```scala mdoc:silent:nest
628
+ import zio.blocks.schema._
629
+
630
+ case class ApiResponse(
631
+ requestId: String,
632
+ timestamp: Option[Long],
633
+ result: Either[String, Int] // Left = error message, Right = status code
634
+ )
635
+ object ApiResponse extends CompanionOptics[ApiResponse] {
636
+ implicit val schema: Schema[ApiResponse] = Schema.derived[ApiResponse]
637
+
638
+ // Optional to the inner Long value (may not exist if None)
639
+ val timestamp: Optional[ApiResponse, Long] = optic(_.timestamp.when[Some[Long]].value)
640
+
641
+ // Optional to the Left value (may not exist if Right)
642
+ val errorMessage: Optional[ApiResponse, String] = optic(_.result.when[Left[String, Int]].value)
643
+
644
+ // Optional to the Right value (may not exist if Left)
645
+ val statusCode: Optional[ApiResponse, Int] = optic(_.result.when[Right[String, Int]].value)
646
+ }
647
+ ```
648
+
649
+ Here is another example focusing on sum types:
650
+
651
+ ```scala
652
+ import zio.blocks.schema._
653
+
654
+ sealed trait Response
655
+ object Response extends CompanionOptics[Response] {
656
+ case class Success(data: String, code: Int) extends Response
657
+ case class Failure(error: String) extends Response
658
+
659
+ implicit val schema: Schema[Response] = Schema.derived
660
+
661
+ // Prism to the Success case
662
+ val success: Prism[Response, Success] = optic(_.when[Success])
663
+
664
+ // Optional: Prism + Lens = Optional
665
+ // Focuses on the 'data' field inside Success case
666
+ val successData : Optional[Response, String] = optic(_.when[Success].data)
667
+ val successCode : Optional[Response, Int ] = optic(_.when[Success].code)
668
+ val failureError: Optional[Response, String] = optic(_.when[Failure].error)
669
+ }
670
+ ```
671
+
672
+ #### Index-based Access with `.at(index)` Syntax
673
+
674
+ We can access elements at specific indices in sequences using `.at(index)` syntax in `optic` macro:
675
+
676
+ ```scala mdoc:silent
677
+ import zio.blocks.schema._
678
+
679
+ case class OrderItem(sku: String, quantity: Int)
680
+ object OrderItem {
681
+ implicit val schema: Schema[OrderItem] = Schema.derived
682
+ }
683
+
684
+ case class Order(id: String, items: List[OrderItem])
685
+ object Order extends CompanionOptics[Order] {
686
+ implicit val schema: Schema[Order] = Schema.derived
687
+
688
+ // Optional for specific indices
689
+ val firstItem : Optional[Order, OrderItem] = optic(_.items.at(0))
690
+ val secondItem: Optional[Order, OrderItem] = optic(_.items.at(1))
691
+
692
+ // Chain with field access
693
+ val firstItemSku : Optional[Order, String] = optic(_.items.at(0).sku)
694
+ val firstItemQuantity: Optional[Order, Int ] = optic(_.items.at(0).quantity)
695
+ }
696
+ ```
697
+
698
+ Usage:
699
+
700
+ ```scala mdoc:compile-only
701
+ val order = Order("ord-1", List(
702
+ OrderItem("SKU-A", 2),
703
+ OrderItem("SKU-B", 1)
704
+ ))
705
+ val emptyOrder = Order("ord-2", List.empty)
706
+
707
+ Order.firstItem.getOption(order) // => Some(OrderItem("SKU-A", 2))
708
+ Order.secondItem.getOption(order) // => Some(OrderItem("SKU-B", 1))
709
+ Order.firstItem.getOption(emptyOrder) // => None (empty list)
710
+
711
+ Order.firstItemSku.getOption(order) // => Some("SKU-A")
712
+ ```
713
+
714
+ #### Key-based Access with `.atKey(key)`
715
+
716
+ To access values at a specific key, we can use `.atKey(key)` syntax in `optic` macro:
717
+
718
+ ```scala mdoc:silent
719
+ import zio.blocks.schema._
720
+
721
+ case class Config(settings: Map[String, String])
722
+ object Config extends CompanionOptics[Config] {
723
+ implicit val schema: Schema[Config] = Schema.derived
724
+
725
+ // Optional for specific keys
726
+ def setting(key: String): Optional[Config, String] = optic(_.settings.atKey(key))
727
+
728
+ // Pre-defined optionals for common keys
729
+ val hostSetting: Optional[Config, String] = optic(_.settings.atKey("host"))
730
+ val portSetting: Optional[Config, String] = optic(_.settings.atKey("port"))
731
+ }
732
+ ```
733
+
734
+ Here is how you can use these optionals:
735
+
736
+ ```scala mdoc:compile-only
737
+ val config = Config(Map("host" -> "localhost", "port" -> "8080"))
738
+
739
+ Config.hostSetting.getOption(config) // => Some("localhost")
740
+ Config.setting("timeout").getOption(config) // => None (key not present)
741
+ ```
742
+
743
+ #### Wrapper Type Access with `.wrapped[T]` Syntax
744
+
745
+ To access the inner value of wrapper types (newtypes, opaque types) you can use the `.wrapped[T]` syntax in `optic` macro:
746
+
747
+ ```scala mdoc:compile-only
748
+ import zio.blocks.schema._
749
+
750
+ // Assume Email is a wrapper around String with validation
751
+ case class Email private (value: String)
752
+ object Email {
753
+ implicit val schema: Schema[Email] = Schema.derived
754
+
755
+ def apply(s: String): Either[String, Email] =
756
+ if (s.contains("@")) Right(new Email(s)) else Left("Invalid email")
757
+ }
758
+
759
+ case class Contact(name: String, email: Email)
760
+ object Contact extends CompanionOptics[Contact] {
761
+ implicit val schema: Schema[Contact] = Schema.derived
762
+
763
+ // Optional to access the wrapped String inside Email
764
+ val emailString: Optional[Contact, String] = optic(_.email.wrapped[String])
765
+ }
766
+ ```
767
+
768
+ ## Traversal
769
+
770
+ A **`Traversal[S, A]`** focuses on zero or more elements within a collection (List, Vector, Set, Map, etc.). Unlike `Lens` (exactly one element) or `Optional` (zero or one element), a `Traversal` can target any number of elements simultaneously.
771
+
772
+ The key methods of `Traversal` are:
773
+
774
+ | Method | Signature | Description |
775
+ |----------------|---------------------------------------------|--------------------------------------------------|
776
+ | `fold` | `(S)(Z, (Z, A) => Z) => Z` | Aggregates all focused values |
777
+ | `reduceOrFail` | `(S)((A, A) => A) => Either[OpticCheck, A]` | Reduces with error handling |
778
+ | `modify` | `(S, A => A) => S` | Applies function to all focused values |
779
+ | `modifyOption` | `(S, A => A) => Option[S]` | Modifies if any elements exist, otherwise `None` |
780
+ | `modifyOrFail` | `(S, A => A) => Either[OpticCheck, S]` | Modifies with detailed error info on failure |
781
+
782
+ ### Manual Traversal Construction
783
+
784
+ Traversals are primarily constructed through the `Traversal` companion object which provides specialized constructors for different collection types:
785
+
786
+ #### Sequence Traversals
787
+
788
+ The two basic constructors for creating traversals over sequences are `seqValues` and `atIndices`:
789
+
790
+ ```scala
791
+ object Traversal {
792
+ // Traverse all elements in a sequence (List, Vector, Set, ArraySeq, etc.)
793
+ def seqValues[A, C[_]](seq: Reflect.Sequence.Bound[A, C]): Traversal[C[A], A]
794
+
795
+ // Traverse specific indices
796
+ def atIndices[A, C[_]](seq: Reflect.Sequence.Bound[A, C], indices: Seq[Int]): Traversal[C[A], A]
797
+ }
798
+ ```
799
+
800
+ The `seqValues` method creates a traversal that focuses on all elements in a sequence, while `atIndices` creates a traversal that focuses only on elements at the specified indices.
801
+
802
+ There are also convenience methods for common collection types:
803
+
804
+ ```scala
805
+ object Traversal {
806
+ def listValues[A] (reflect: Reflect.Bound[A]): Traversal[List[A], A]
807
+ def vectorValues[A] (reflect: Reflect.Bound[A]): Traversal[Vector[A], A]
808
+ def setValues[A] (reflect: Reflect.Bound[A]): Traversal[Set[A], A]
809
+ def arraySeqValues[A](reflect: Reflect.Bound[A]): Traversal[ArraySeq[A], A]
810
+ }
811
+ ```
812
+
813
+ For example, to create a traversal over all items in a shopping cart represented as a list and quantities as a vector, you can do the following:
814
+
815
+ ```scala mdoc:compile-only
816
+ import zio.blocks.schema._
817
+
818
+ case class ShoppingCart(items: List[String], quantities: Vector[Int])
819
+ object ShoppingCart {
820
+ implicit val schema: Schema[ShoppingCart] = Schema.derived
821
+
822
+ // Manual traversal for all items in the cart
823
+ val allItems: Traversal[List[String], String] =
824
+ Traversal.listValues(Schema[String].reflect)
825
+
826
+ // Manual traversal for all quantities
827
+ val allQuantities: Traversal[Vector[Int], Int] =
828
+ Traversal.vectorValues(Schema[Int].reflect)
829
+ }
830
+ ```
831
+
832
+ #### Map Traversals
833
+
834
+ To create traversals over keys or values in a map you can use the following constructors:
835
+
836
+ ```scala
837
+ object Traversal {
838
+ // Traverse all keys in a map
839
+ def mapKeys[K, V, M[_, _]](map: Reflect.Map.Bound[K, V, M]): Traversal[M[K, V], K]
840
+
841
+ // Traverse all values in a map
842
+ def mapValues[K, V, M[_, _]](map: Reflect.Map.Bound[K, V, M]): Traversal[M[K, V], V]
843
+
844
+ // Traverse values at specific keys
845
+ def atKeys[K, V, M[_, _]](map: Reflect.Map.Bound[K, V, M], keys: Seq[K]): Traversal[M[K, V], V]
846
+ }
847
+ ```
848
+
849
+ Let's say you have an inventory represented as a map of product names to stock counts. You can create traversals for both keys and values as follows:
850
+
851
+ ```scala mdoc:compile-only
852
+ import zio.blocks.schema._
853
+
854
+ case class Inventory(stock: Map[String, Int])
855
+ object Inventory {
856
+ implicit val schema: Schema[Inventory] = Schema.derived
857
+
858
+ // Manual traversal for all product names (keys)
859
+ val productNames: Traversal[Map[String, Int], String] =
860
+ Traversal.mapKeys(Schema[Map[String, Int]].reflect.asMap.get)
861
+
862
+ // Manual traversal for all stock counts (values)
863
+ val stockCounts: Traversal[Map[String, Int], Int] =
864
+ Traversal.mapValues(Schema[Map[String, Int]].reflect.asMap.get)
865
+ }
866
+ ```
867
+
868
+ ### Automatic Macro-Based Traversal Derivation
869
+
870
+ The `optic` macro inside the `CompanionOptics` trait creates traversals using intuitive selector syntax with the `.each`, `.eachKey`, and `.eachValue` methods.
871
+
872
+ 1. Use `.each` to traverse all elements in a sequence (List, Vector, Set, ArraySeq):
873
+
874
+ ```scala mdoc:compile-only
875
+ import zio.blocks.schema._
876
+
877
+ case class Order(id: String, items: List[String], prices: Vector[Double])
878
+ object Order extends CompanionOptics[Order] {
879
+ implicit val schema: Schema[Order] = Schema.derived
880
+
881
+ // Traversal over all items
882
+ val allItems: Traversal[Order, String] = optic(_.items.each)
883
+
884
+ // Traversal over all prices
885
+ val allPrices: Traversal[Order, Double] = optic(_.prices.each)
886
+ }
887
+ ```
888
+
889
+ 2. Use `.eachKey` to traverse all keys and `.eachValue` to traverse all values in a map:
890
+
891
+ ```scala mdoc:compile-only
892
+ import zio.blocks.schema._
893
+
894
+ case class UserScores(scores: Map[String, Int])
895
+ object UserScores extends CompanionOptics[UserScores] {
896
+ implicit val schema: Schema[UserScores] = Schema.derived
897
+
898
+ // Traversal over all user names (keys)
899
+ val allUserNames: Traversal[UserScores, String] = optic(_.scores.eachKey)
900
+
901
+ // Traversal over all scores (values)
902
+ val allScores: Traversal[UserScores, Int] = optic(_.scores.eachValue)
903
+ }
904
+ ```
905
+
906
+ 3. Use `.atIndices(indices)` to traverse elements at specific indices:
907
+
908
+ ```scala mdoc:compile-only
909
+ import zio.blocks.schema._
910
+
911
+ case class Matrix(rows: Vector[Vector[Int]])
912
+ object Matrix extends CompanionOptics[Matrix] {
913
+ implicit val schema: Schema[Matrix] = Schema.derived
914
+
915
+ // Traverse elements at indices 0, 2, and 4
916
+ val selectedRows: Traversal[Matrix, Vector[Int]] = optic(_.rows.atIndices(0, 2, 4))
917
+ }
918
+ ```
919
+
920
+ 4. Use `.atKeys(keys)` to traverse values at specific keys:
921
+
922
+ ```scala mdoc:silent
923
+ import zio.blocks.schema._
924
+
925
+ case class Environment(variables: Map[String, String])
926
+ object Environment extends CompanionOptics[Environment] {
927
+ implicit val schema: Schema[Environment] = Schema.derived
928
+
929
+ // Traverse values for specific environment variables
930
+ val criticalVars: Traversal[Environment, String] = optic(_.variables.atKeys("PATH", "HOME", "USER"))
931
+ }
932
+ ```
933
+
934
+ Please note that the `optic` macro supports chaining traversals with field access for deep navigation:
935
+
936
+ ```scala mdoc:compile-only
937
+ import zio.blocks.schema._
938
+
939
+ case class LineItem(sku: String, price: Double, quantity: Int)
940
+ object LineItem {
941
+ implicit val schema: Schema[LineItem] = Schema.derived
942
+ }
943
+
944
+ case class Invoice(id: String, items: List[LineItem])
945
+ object Invoice extends CompanionOptics[Invoice] {
946
+ implicit val schema: Schema[Invoice] = Schema.derived
947
+
948
+ // Traverse to get all SKUs from all items
949
+ val allSkus: Traversal[Invoice, String] = optic(_.items.each.sku)
950
+
951
+ // Traverse to get all prices from all items
952
+ val allPrices: Traversal[Invoice, Double] = optic(_.items.each.price)
953
+
954
+ // Traverse to get all quantities from all items
955
+ val allQuantities: Traversal[Invoice, Int] = optic(_.items.each.quantity)
956
+ }
957
+ ```
958
+
959
+ ### Operations
960
+
961
+ Let's explore traversal operations with a practical example. Assume we have a `Team` case class with a list of members and a map of scores:
962
+
963
+ ```scala mdoc:silent
964
+ import zio.blocks.schema._
965
+
966
+ case class Team(name: String, members: List[String], scores: Map[String, Int])
967
+ object Team extends CompanionOptics[Team] {
968
+ implicit val schema: Schema[Team] = Schema.derived[Team]
969
+
970
+ val allMembers : Traversal[Team, String] = optic(_.members.each)
971
+ val allScores : Traversal[Team, Int ] = optic(_.scores.eachValue)
972
+ val allPlayerNames: Traversal[Team, String] = optic(_.scores.eachKey)
973
+ }
974
+
975
+ val team = Team(
976
+ "Alpha",
977
+ List("Alice", "Bob", "Charlie"),
978
+ Map("Alice" -> 100, "Bob" -> 85, "Charlie" -> 92)
979
+ )
980
+ ```
981
+
982
+ 1. `Traversal#fold` aggregates all focused values:
983
+
984
+ ```scala mdoc:compile-only
985
+ // Count all members
986
+ Team.allMembers.fold(team)(0, (count, _) => count + 1)
987
+ // => 3
988
+
989
+ // Sum all scores
990
+ Team.allScores.fold(team)(0, _ + _)
991
+ // => 277
992
+
993
+ // Concatenate all member names
994
+ Team.allMembers.fold(team)("", (acc, name) => if (acc.isEmpty) name else s"$acc, $name")
995
+ // => "Alice, Bob, Charlie"
996
+ ```
997
+
998
+ 2. `Traversal#reduceOrFail` reduces with error handling:
999
+
1000
+ ```scala mdoc:compile-only
1001
+ // Find the maximum score
1002
+ Team.allScores.reduceOrFail(team)(math.max)
1003
+ // => Right(100)
1004
+
1005
+ // Find the minimum score
1006
+ Team.allScores.reduceOrFail(team)(math.min)
1007
+ // => Right(85)
1008
+
1009
+ // Attempt to reduce an empty collection
1010
+ val emptyTeam = Team("Empty", Nil, Map.empty)
1011
+ Team.allMembers.reduceOrFail(emptyTeam)(_ + _)
1012
+ // => Left(OpticCheck(...)) with EmptySequence error
1013
+ ```
1014
+
1015
+ 3. `Traversal#modify` — Apply function to all focused values:
1016
+
1017
+ ```scala
1018
+ // Convert all member names to uppercase
1019
+ Team.allMembers.modify(team, _.toUpperCase)
1020
+ // => Team("Alpha", List("ALICE", "BOB", "CHARLIE"), Map(...))
1021
+
1022
+ // Double all scores
1023
+ Team.allScores.modify(team, _ * 2)
1024
+ // => Team("Alpha", List(...), Map("Alice" -> 200, "Bob" -> 170, "Charlie" -> 184))
1025
+
1026
+ // Add prefix to all player names (keys)
1027
+ Team.allPlayerNames.modify(team, name => s"Player: $name")
1028
+ // => Team("Alpha", List(...), Map("Player: Alice" -> 100, ...))
1029
+ ```
1030
+
1031
+ 4. `Traversal#modifyOption` — Modify returning Option
1032
+
1033
+ ```scala mdoc:compile-only
1034
+ // Modify non-empty collection
1035
+ Team.allMembers.modifyOption(team, _.toUpperCase)
1036
+ // => Some(Team("Alpha", List("ALICE", "BOB", "CHARLIE"), Map(...)))
1037
+
1038
+ // Modify empty collection
1039
+ val emptyTeam = Team("Empty", Nil, Map.empty)
1040
+ Team.allMembers.modifyOption(emptyTeam, _.toUpperCase)
1041
+ // => None
1042
+ ```
1043
+
1044
+ 5. `Traversal#modifyOrFail` — Modify with detailed error on failure
1045
+
1046
+ ```scala mdoc:compile-only
1047
+ // Successful modification
1048
+ Team.allMembers.modifyOrFail(team, _.toUpperCase)
1049
+ // => Right(Team("Alpha", List("ALICE", "BOB", "CHARLIE"), Map(...)))
1050
+
1051
+ // Failed modification (empty collection)
1052
+ val emptyTeam = Team("Empty", Nil, Map.empty)
1053
+ Team.allMembers.modifyOrFail(emptyTeam, _.toUpperCase)
1054
+ // => Left(OpticCheck(List(EmptySequence(...))))
1055
+ ```
1056
+
1057
+ ## Debug-Friendly toString
1058
+
1059
+ All optic types (`Lens`, `Prism`, `Optional`, `Traversal`) have a custom `toString` that produces output matching the `optic` macro syntax. This makes debugging easier by showing exactly what path the optic represents:
1060
+
1061
+ ```scala
1062
+ import zio.blocks.schema._
1063
+
1064
+ case class Person(name: String, address: Address)
1065
+ case class Address(street: String, city: String)
1066
+
1067
+ object Person extends CompanionOptics[Person] {
1068
+ implicit val schema: Schema[Person] = Schema.derived
1069
+ val street: Lens[Person, String] = optic(_.address.street)
1070
+ }
1071
+
1072
+ println(Person.street) // Output: Lens(_.address.street)
1073
+ ```
1074
+
1075
+ **Examples by optic type:**
1076
+
1077
+ | Optic | toString Output |
1078
+ |-------|-----------------|
1079
+ | `Lens` for field | `Lens(_.name)` |
1080
+ | `Lens` for nested field | `Lens(_.address.street)` |
1081
+ | `Prism` for variant case | `Prism(_.when[CreditCard])` |
1082
+ | `Optional` combining prism + lens | `Optional(_.when[Success].data)` |
1083
+ | `Traversal` over sequence | `Traversal(_.items.each)` |
1084
+ | `Traversal` over map keys | `Traversal(_.metadata.eachKey)` |
1085
+ | `Traversal` over map values | `Traversal(_.metadata.eachValue)` |
1086
+
1087
+ The output mirrors what you would write with the `optic` macro, making it easy to understand and reproduce the optic path.
1088
+
1089
+ ## Composing Optics
1090
+
1091
+ All optics can be composed together to create more complex access paths. All optics that extend the base `Optic` trait support composition via the `apply` method, which takes another optic as an argument and returns a new optic representing the combined access path:
1092
+ ```scala
1093
+ trait Optic[S, A] {
1094
+ def apply[B](that: Lens[A, B]): Optic[S, B]
1095
+ def apply[B <: A](that: Prism[A, B]): Optic[S, B]
1096
+ def apply[B](that: Optional[A, B]): Optic[S, B]
1097
+ def apply[B](that: Traversal[A, B]): Traversal[S, B]
1098
+ }
1099
+ ```
1100
+
1101
+ All optics (`Lens`, `Prism`, `Optional`, `Traversal`) implement the above `apply` methods to support composition with other optics. Additionally, they have `apply` overloads on their companion objects that take two optics as arguments and return the composed optic.
1102
+
1103
+ The following table summarizes the composition rules for combining different optic types:
1104
+
1105
+ | `this` ↓ / `that` → | **`Lens`** | **`Prism`** | **`Optional`** | **`Traversal`** |
1106
+ |---------------------|-------------|-------------|----------------|-----------------|
1107
+ | **`Lens`** | `Lens` | `Optional` | `Optional` | `Traversal` |
1108
+ | **`Prism`** | `Optional` | `Prism` | `Optional` | `Traversal` |
1109
+ | **`Optional`** | `Optional` | `Optional` | `Optional` | `Traversal` |
1110
+ | **`Traversal`** | `Traversal` | `Traversal` | `Traversal` | `Traversal` |
1111
+
1112
+ Here are some important notes regarding optic composition:
1113
+
1114
+ 1. As the table demonstrates, `Traversal` is the most general optic type, as composing with it always results in a `Traversal`. This behavior occurs because `Traversal` focuses on zero or more elements, and once you have that level of generality, you cannot return to a more specific optic type. This is the **absorption property** encoded in the type system.
1115
+
1116
+ 2. Using `Optic#apply` instead of the `apply` methods on companion objects allows for a more natural DSL-like syntax for composing optics. For example, instead of writing `Lens(Person.address, Address.postalCode)`, we can write `Person.address(Address.postalCode)`, which reads naturally as "address's postal code".
1117
+
1118
+ 3. `Optional` is an absorbing optic for `Lens` and `Prism`—once you have partiality in your access path, composing with total optics (`Lens`) or other partial optics (`Prism`, `Optional`) preserves that partiality. `Traversal` is the most general optic, as composing with it always results in a `Traversal`.
1119
+
1120
+ ### Examples
1121
+
1122
+ In this section, we explore the composition of `Lens` with other optics. The composition of other optics follows similar patterns.
1123
+
1124
+ #### Composing a Lens with Another Lens
1125
+
1126
+ We can chain two lenses to focus deeper into nested structures using the following composition operators:
1127
+
1128
+ ```scala
1129
+ object Lens {
1130
+ def apply[S, T, A](
1131
+ first : Lens[S, T], // S => T
1132
+ second: Lens[T, A] // T => A
1133
+ ): Lens[S, A] = ??? // S => A
1134
+ }
1135
+ ```
1136
+
1137
+ This method takes two lenses: the first from `S` to `T`, and the second from `T` to `A`, and returns a new lens from `S` to `A`.
1138
+
1139
+ For example, if we have a `Person` case class that contains an `Address` and we want to create a lens to access the `street` field of the `Address` within `Person`, we can do so as follows:
1140
+
1141
+ ```scala mdoc:compile-only
1142
+ import zio.blocks.schema._
1143
+
1144
+ case class Address(street: String, city: String)
1145
+ object Address extends CompanionOptics[Address] {
1146
+ implicit val schema: Schema[Address] = Schema.derived
1147
+
1148
+ val street: Lens[Address, String] =
1149
+ Lens[Address, String](
1150
+ Schema[Address].reflect.asRecord.get,
1151
+ Schema[String].reflect.asTerm("street")
1152
+ )
1153
+
1154
+ val city: Lens[Address, String] =
1155
+ Lens[Address, String](
1156
+ Schema[Address].reflect.asRecord.get,
1157
+ Schema[String].reflect.asTerm("city")
1158
+ )
1159
+ }
1160
+ case class Person(name: String, age: Int, address: Address)
1161
+ object Person {
1162
+ import zio.blocks.schema._
1163
+ implicit val schema: Schema[Person] = Schema.derived[Person]
1164
+
1165
+ val address: Lens[Person, Address] =
1166
+ Lens[Person, Address](
1167
+ Schema[Person].reflect.asRecord.get,
1168
+ Schema[Address].reflect.asTerm("address")
1169
+ )
1170
+
1171
+ val street: Lens[Person, String] =
1172
+ Lens[Person, Address, String](
1173
+ Person.address, // Lens from Person to Address
1174
+ Address.street // Lens from Address to String (street field)
1175
+ )
1176
+ }
1177
+ ```
1178
+
1179
+ To make the DSL more convenient, we can use the `Lens#apply` method:
1180
+
1181
+ ```scala
1182
+ object Person {
1183
+ val street: Lens[Person, String] = Person.address(Address.street)
1184
+ }
1185
+ ```
1186
+
1187
+ The `optic` macro (or its alias `$`) provides a more concise way to derive composed lenses. By extending `CompanionOptics[T]` and using selector syntax, you can derive the same lenses with significantly less boilerplate:
1188
+
1189
+ ```scala mdoc:compile-only
1190
+ import zio.blocks.schema._
1191
+
1192
+ case class Address(street: String, city: String)
1193
+ object Address extends CompanionOptics[Address]
1194
+ case class Person(name: String, age: Int, address: Address)
1195
+ object Person extends CompanionOptics[Person] {
1196
+ implicit val schema: Schema[Person] = Schema.derived
1197
+
1198
+ // Simple field lens
1199
+ val address: Lens[Person, Address] = optic(_.address)
1200
+
1201
+ // Composed lens - directly access nested field via path syntax
1202
+ val street: Lens[Person, String] = optic(_.address.street)
1203
+ val city : Lens[Person, String] = optic(_.address.city)
1204
+ }
1205
+ ```
1206
+
1207
+ The `optic` macro inspects the selector path and automatically composes the necessary lenses. The path `_.address.street` is expanded into a composition of `Person → Address` and `Address → String` lenses.
1208
+
1209
+ #### Composing a Lens with a Prism
1210
+
1211
+ When you compose a `Lens` with a `Prism`, the result is an `Optional`. This makes sense because a `Lens` always succeeds (fields always exist in records), whereas a `Prism` may fail (a value may not match the particular case being targeted). Therefore, the composition may fail, which is exactly what `Optional` represents:
1212
+
1213
+ ```scala
1214
+ sealed trait Lens[S, A] extends Optic[S, A] {
1215
+ def apply[B <: A](that: Prism[A, B]): Optional[S, B]
1216
+ }
1217
+ ```
1218
+
1219
+ Consider a scenario where you have a record containing a field whose type is a sealed trait (sum type), and you want to focus on a specific case of that sum type.
1220
+
1221
+ For instance, suppose we have an `Employee` case class that contains a `ContactInfo` field, where `ContactInfo` is a sealed trait with different cases:
1222
+
1223
+ ```scala
1224
+ import zio.blocks.schema._
1225
+
1226
+ sealed trait ContactInfo
1227
+
1228
+ object ContactInfo {
1229
+ case class Email(address: String) extends ContactInfo
1230
+
1231
+ object Email {
1232
+ implicit val schema: Schema[Email] = Schema.derived[Email]
1233
+
1234
+ val address: Lens[Email, String] =
1235
+ Lens[Email, String](
1236
+ Schema[Email].reflect.asRecord.get,
1237
+ Schema[String].reflect.asTerm("address")
1238
+ )
1239
+ }
1240
+
1241
+ case class Phone(number: String) extends ContactInfo
1242
+
1243
+ object Phone {
1244
+ implicit val schema: Schema[Phone] = Schema.derived[Phone]
1245
+ }
1246
+
1247
+ case object NoContact extends ContactInfo
1248
+
1249
+ implicit val schema: Schema[ContactInfo] = Schema.derived[ContactInfo]
1250
+
1251
+ // Prism to focus on the Email case
1252
+ lazy val email: Prism[ContactInfo, Email] =
1253
+ Prism[ContactInfo, Email](
1254
+ Schema[ContactInfo].reflect.asVariant.get,
1255
+ Schema[Email].reflect.asTerm("Email")
1256
+ )
1257
+
1258
+ // Prism to focus on the Phone case
1259
+ lazy val phone: Prism[ContactInfo, Phone] =
1260
+ Prism[ContactInfo, Phone](
1261
+ Schema[ContactInfo].reflect.asVariant.get,
1262
+ Schema[Phone].reflect.asTerm("Phone")
1263
+ )
1264
+ }
1265
+
1266
+ case class Employee(name: String, contact: ContactInfo)
1267
+
1268
+ object Employee {
1269
+ implicit val schema: Schema[Employee] = Schema.derived[Employee]
1270
+
1271
+ // Lens to focus on the contact field
1272
+ val contact: Lens[Employee, ContactInfo] =
1273
+ Lens[Employee, ContactInfo](
1274
+ Schema[Employee].reflect.asRecord.get,
1275
+ Schema[ContactInfo].reflect.asTerm("contact")
1276
+ )
1277
+
1278
+ // Compose Lens with Prism to get an Optional
1279
+ // This focuses on the email address, but only if contact is an Email
1280
+ val contactEmail: Optional[Employee, ContactInfo.Email] =
1281
+ Employee.contact(ContactInfo.email)
1282
+
1283
+ val emailAddress: Optional[Employee, String] =
1284
+ Employee.contactEmail(ContactInfo.Email.address)
1285
+ }
1286
+ ```
1287
+
1288
+ The `Employee.contactEmail` is an `Optional[Employee, ContactInfo.Email]` that allows you to access or modify the email contact of an employee, but only if their contact information is indeed an `Email`. If the contact information is a `Phone` or `NoContact`, the operations will fail gracefully, returning `None`:
1289
+
1290
+ ```scala
1291
+ val employee1 = Employee("Alice", ContactInfo.Email("alice@example.com"))
1292
+ // getOption returns Some when the contact is an Email
1293
+ Employee.contactEmail.getOption(employee1) // => Some(Email("alice@example.com"))
1294
+
1295
+ // replace only succeeds if the contact is already an Email
1296
+ Employee.contactEmail.replaceOption(employee1, ContactInfo.Email("newalice@example.com"))
1297
+ // => Some(Employee("Alice", Email("newalice@example.com")))
1298
+
1299
+ val employee2 = Employee("Bob", ContactInfo.Phone("555-1234"))
1300
+ // getOption returns None when the contact is NOT an Email
1301
+ Employee.contactEmail.getOption(employee2) // => None
1302
+
1303
+ Employee.contactEmail.replaceOption(employee2, ContactInfo.Email("bob@example.com"))
1304
+ // => None (Bob's contact is a Phone, not an Email)
1305
+ ```
1306
+
1307
+ You can further compose this `Optional` to reach deeper into the structure:
1308
+
1309
+ ```scala
1310
+ // Now you can get/set the email address string directly
1311
+ Employee.emailAddress.getOption(employee1)
1312
+ // => Some("alice@example.com")
1313
+
1314
+ Employee.emailAddress.getOption(employee2)
1315
+ // => None
1316
+ ```
1317
+
1318
+ The `optic` macro supports case selection using the `.when[T]` syntax, which makes composing lenses with prisms much more concise:
1319
+
1320
+ ```scala mdoc:silent:nest
1321
+ import zio.blocks.schema._
1322
+
1323
+ sealed trait ContactInfo
1324
+
1325
+ object ContactInfo extends CompanionOptics[ContactInfo] {
1326
+ case class Email(address: String) extends ContactInfo
1327
+ case class Phone(number: String) extends ContactInfo
1328
+ case object NoContact extends ContactInfo
1329
+
1330
+ implicit val schema: Schema[ContactInfo] = Schema.derived
1331
+
1332
+ // Derive prisms using the optic macro with .when[T] syntax
1333
+ val email: Prism[ContactInfo, Email] = optic(_.when[Email])
1334
+ val phone: Prism[ContactInfo, Phone] = optic(_.when[Phone])
1335
+ }
1336
+
1337
+ case class Employee(name: String, contact: ContactInfo)
1338
+
1339
+ object Employee extends CompanionOptics[Employee] {
1340
+ implicit val schema: Schema[Employee] = Schema.derived
1341
+
1342
+ // Simple field lens
1343
+ val contact: Lens[Employee, ContactInfo] = optic(_.contact)
1344
+
1345
+ // Compose Lens with Prism using path syntax - result is an Optional
1346
+ val contactEmail: Optional[Employee, ContactInfo.Email] =
1347
+ optic(_.contact.when[ContactInfo.Email])
1348
+
1349
+ // Chain even deeper to get the email address string
1350
+ val emailAddress: Optional[Employee, String] =
1351
+ optic(_.contact.when[ContactInfo.Email].address)
1352
+
1353
+ // Similarly for phone
1354
+ val phoneNumber: Optional[Employee, String] =
1355
+ optic(_.contact.when[ContactInfo.Phone].number)
1356
+ }
1357
+ ```
1358
+
1359
+ The path `_.contact.when[Email].address` is automatically composed into a `Lens → Prism → Lens` chain, producing an `Optional[Employee, String]` optic.
1360
+
1361
+ #### Composing a Lens with an Optional
1362
+
1363
+ When you compose a `Lens` with an `Optional`, the result is also an `Optional`. This follows naturally: a `Lens` always succeeds, and an `Optional` may fail; therefore, the composition may fail.
1364
+
1365
+ Building on our previous example, suppose we have a `Company` that contains an `Employee`:
1366
+
1367
+ ```scala mdoc:silent
1368
+ case class Company(name: String, ceo: Employee)
1369
+ object Company {
1370
+ implicit val schema: Schema[Company] = Schema.derived[Company]
1371
+
1372
+ // Lens to focus on the CEO
1373
+ val ceo: Lens[Company, Employee] =
1374
+ Lens[Company, Employee](
1375
+ Schema[Company].reflect.asRecord.get,
1376
+ Schema[Employee].reflect.asTerm("ceo")
1377
+ )
1378
+
1379
+ // Compose Lens with Optional to get another Optional
1380
+ // This chains: Company -> ceo (Lens) -> contactEmail (Optional)
1381
+ val ceoEmailContact: Optional[Company, ContactInfo.Email] =
1382
+ Company.ceo(Employee.contactEmail)
1383
+
1384
+ // Further composition to get the email address string
1385
+ val ceoEmailAddress: Optional[Company, String] =
1386
+ Company.ceo(Employee.emailAddress)
1387
+ }
1388
+ ```
1389
+
1390
+ Now you can work with the CEO's email contact through the company:
1391
+
1392
+ ```scala mdoc:compile-only
1393
+ val techCorp = Company("TechCorp", Employee("Alice", ContactInfo.Email("alice@tech.com")))
1394
+ val retailCo = Company("RetailCo", Employee("Bob", ContactInfo.Phone("555-9999")))
1395
+
1396
+ // Get the CEO's email contact
1397
+ Company.ceoEmailContact.getOption(techCorp)
1398
+ // => Some(Email("alice@tech.com"))
1399
+
1400
+ Company.ceoEmailContact.getOption(retailCo)
1401
+ // => None (Bob's contact is a Phone)
1402
+
1403
+ // Get the CEO's email address string
1404
+ Company.ceoEmailAddress.getOption(techCorp)
1405
+ // => Some("alice@tech.com")
1406
+
1407
+ // Update the CEO's email address
1408
+ Company.ceoEmailAddress.replaceOption(techCorp, "ceo@tech.com")
1409
+ // => Some(Company("TechCorp", Employee("Alice", Email("ceo@tech.com"))))
1410
+
1411
+ Company.ceoEmailAddress.replaceOption(retailCo, "bob@retail.com")
1412
+ // => None (cannot replace because Bob does not have an Email contact)
1413
+ ```
1414
+
1415
+ This composition pattern is powerful for navigating through complex nested structures where some paths may not be valid for all values.
1416
+
1417
+ With the `optic` macro, you can express the entire path in a single selector expression:
1418
+
1419
+ ```scala mdoc:compile-only
1420
+ import zio.blocks.schema._
1421
+
1422
+ case class Company(name: String, ceo: Employee)
1423
+ object Company extends CompanionOptics[Company] {
1424
+ implicit val schema: Schema[Company] = Schema.derived
1425
+
1426
+ // Simple field lens
1427
+ val ceo: Lens[Company, Employee] = optic(_.ceo)
1428
+
1429
+ // Compose through multiple levels using path syntax
1430
+ // Lens (ceo) + Lens (contact) + Prism (when[Email]) = Optional
1431
+ val ceoEmailContact: Optional[Company, ContactInfo.Email] =
1432
+ optic(_.ceo.contact.when[ContactInfo.Email])
1433
+
1434
+ // Go even deeper to the email address string
1435
+ val ceoEmailAddress: Optional[Company, String] =
1436
+ optic(_.ceo.contact.when[ContactInfo.Email].address)
1437
+
1438
+ // Similarly for phone number
1439
+ val ceoPhoneNumber: Optional[Company, String] =
1440
+ optic(_.ceo.contact.when[ContactInfo.Phone].number)
1441
+ }
1442
+ ```
1443
+
1444
+ The macro automatically determines the correct output type based on the composition rules: since the path includes a `.when[Email]` prism selector, the result is an `Optional`.
1445
+
1446
+ #### Composing a Lens with a Traversal
1447
+
1448
+ When you compose a `Lens` with a `Traversal`, the result is a `Traversal`. This combination allows you to:
1449
+
1450
+ - First zoom into a specific field of a record (via the `Lens`)
1451
+ - Then iterate over all elements in a collection at that field (via the `Traversal`)
1452
+
1453
+ If you have a record containing a collection field and you want to operate on all elements of that collection, you can use this composition.
1454
+
1455
+ For example, let's create a `Department` that has a list of employees, and we want to access all employee names:
1456
+
1457
+ ```scala mdoc:compile-only
1458
+ case class Department(name: String, employees: List[Employee])
1459
+ object Department {
1460
+ implicit val schema: Schema[Department] = Schema.derived[Department]
1461
+
1462
+ // Lens to focus on the employees list
1463
+ val employees: Lens[Department, List[Employee]] =
1464
+ Lens[Department, List[Employee]](
1465
+ Schema[Department].reflect.asRecord.get,
1466
+ Schema[List[Employee]].reflect.asTerm("employees")
1467
+ )
1468
+
1469
+ // Traversal to iterate over all elements in the list
1470
+ val eachEmployee: Traversal[List[Employee], Employee] =
1471
+ Traversal.listValues(Schema[Employee].reflect)
1472
+
1473
+ // Compose Lens with Traversal
1474
+ // This focuses on all employees in the department
1475
+ val allEmployees: Traversal[Department, Employee] =
1476
+ Department.employees(Department.eachEmployee)
1477
+ }
1478
+ ```
1479
+
1480
+ With this `Traversal`, you can fold over all employees or modify them:
1481
+
1482
+ ```scala
1483
+ val engineering = Department(
1484
+ "Engineering",
1485
+ List(
1486
+ Employee("Alice", ContactInfo.Email("alice@company.com")),
1487
+ Employee("Bob", ContactInfo.Phone("555-1234")),
1488
+ Employee("Charlie", ContactInfo.Email("charlie@company.com"))
1489
+ )
1490
+ )
1491
+
1492
+ // Fold to collect all employee names
1493
+ Department.allEmployees.fold(engineering)(List.empty[String], (acc, emp) => acc :+ emp.name)
1494
+ // => List("Alice", "Bob", "Charlie")
1495
+
1496
+ // Modify all employees (e.g., update their contact to NoContact)
1497
+ Department.allEmployees.modify(engineering, emp => emp.copy(contact = ContactInfo.NoContact))
1498
+ // => Department("Engineering", List(
1499
+ // Employee("Alice", NoContact),
1500
+ // Employee("Bob", NoContact),
1501
+ // Employee("Charlie", NoContact)
1502
+ // ))
1503
+ ```
1504
+
1505
+ You can chain further to go even deeper. For example, to access all employee names in a department:
1506
+
1507
+ ```scala
1508
+ object Employee {
1509
+ val name: Lens[Employee, String] =
1510
+ Lens[Employee, String](
1511
+ Schema[Employee].reflect.asRecord.get,
1512
+ Schema[String].reflect.asTerm("name")
1513
+ )
1514
+ }
1515
+
1516
+ object Department {
1517
+ // Chain: Department -> employees (Lens) -> each (Traversal) -> name (Lens)
1518
+ // Result type: Traversal[Department, String]
1519
+ val allEmployeeNames: Traversal[Department, String] =
1520
+ Department.allEmployees(Employee.name)
1521
+ }
1522
+
1523
+ // Fold to get all names
1524
+ Department.allEmployeeNames.fold(engineering)(List.empty[String], (acc, name) => acc :+ name)
1525
+ // => List("Alice", "Bob", "Charlie")
1526
+
1527
+ // Modify all names (e.g., convert them to uppercase)
1528
+ Department.allEmployeeNames.modify(engineering, _.toUpperCase)
1529
+ // => Department("Engineering", List(
1530
+ // Employee("ALICE", ...),
1531
+ // Employee("BOB", ...),
1532
+ // Employee("CHARLIE", ...)
1533
+ // ))
1534
+ ```
1535
+
1536
+ You can even compose a `Traversal` with a `Prism` to filter elements. For example, to retrieve only the email addresses from employees who have email contacts:
1537
+
1538
+ ```scala
1539
+ object Department {
1540
+ // Chain: Department -> allEmployees (Traversal) -> contactEmail (Optional)
1541
+ // Traversal + Optional = Traversal
1542
+ val allEmailContacts: Traversal[Department, ContactInfo.Email] =
1543
+ Department.allEmployees(Employee.contactEmail)
1544
+
1545
+ // Further chain to get email address strings
1546
+ val allEmailAddresses: Traversal[Department, String] =
1547
+ Department.allEmailContacts(ContactInfo.Email.address)
1548
+ }
1549
+
1550
+ // This only folds over employees who have Email contacts
1551
+ Department.allEmailAddresses.fold(engineering)(List.empty[String], (acc, addr) => acc :+ addr)
1552
+ // => List("alice@company.com", "charlie@company.com")
1553
+ // Note: Bob is skipped because he has a Phone contact
1554
+ ```
1555
+
1556
+ This example demonstrates the power of optics composition: you can build complex data access paths by combining simple, reusable building blocks, and the type system ensures the resulting optic has the correct semantics (always succeeding, potentially failing, or iterating over multiple values).
1557
+
1558
+ The `optic` macro supports collection traversal using the `.each` syntax. This allows you to express traversals over lists, vectors, and other sequences in a concise path expression:
1559
+
1560
+ ```scala mdoc:compile-only
1561
+ import zio.blocks.schema._
1562
+
1563
+ case class Department(name: String, employees: List[Employee])
1564
+ object Department extends CompanionOptics[Department] {
1565
+ implicit val schema: Schema[Department] = Schema.derived
1566
+
1567
+ // Lens to the employees list
1568
+ val employees: Lens[Department, List[Employee]] = optic(_.employees)
1569
+
1570
+ // Traversal over all employees using .each syntax
1571
+ val allEmployees: Traversal[Department, Employee] = optic(_.employees.each)
1572
+
1573
+ // Chain deeper: Lens + Traversal + Lens = Traversal
1574
+ val allEmployeeNames: Traversal[Department, String] = optic(_.employees.each.name)
1575
+
1576
+ // Lens + Traversal + Lens + Prism = Traversal
1577
+ val allEmailContacts: Traversal[Department, ContactInfo.Email] =
1578
+ optic(_.employees.each.contact.when[ContactInfo.Email])
1579
+
1580
+ // Go even deeper to get email address strings
1581
+ val allEmailAddresses: Traversal[Department, String] =
1582
+ optic(_.employees.each.contact.when[ContactInfo.Email].address)
1583
+ }
1584
+ ```
1585
+
1586
+ The path `_.employees.each.contact.when[Email].address` composes:
1587
+ 1. `employees` — a `Lens` to the `List`
1588
+ 2. `.each` — a `Traversal` over list elements
1589
+ 3. `contact` — a `Lens` to `ContactInfo`
1590
+ 4. `.when[Email]` — a `Prism` to the `Email` case
1591
+ 5. `address` — a `Lens` to the `String`
1592
+
1593
+ The result is a `Traversal[Department, String]` that focuses on all email addresses in the department.
1594
+
1595
+ For maps, the macro provides `.eachKey` and `.eachValue` for traversing keys and values, respectively:
1596
+
1597
+ ```scala mdoc:compile-only
1598
+ import zio.blocks.schema._
1599
+
1600
+ case class Inventory(items: Map[String, Int])
1601
+ object Inventory extends CompanionOptics[Inventory] {
1602
+ implicit val schema: Schema[Inventory] = Schema.derived
1603
+
1604
+ // Lens to the items map
1605
+ val items: Lens[Inventory, Map[String, Int]] = optic(_.items)
1606
+
1607
+ // Traversal over all keys
1608
+ val allItemNames: Traversal[Inventory, String] = optic(_.items.eachKey)
1609
+
1610
+ // Traversal over all values
1611
+ val allQuantities: Traversal[Inventory, Int] = optic(_.items.eachValue)
1612
+ }
1613
+ ```