@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.
- package/index.md +426 -0
- package/package.json +6 -0
- package/path-interpolator.md +645 -0
- package/reference/binding.md +364 -0
- package/reference/chunk.md +576 -0
- package/reference/context.md +157 -0
- package/reference/docs.md +524 -0
- package/reference/dynamic-value.md +823 -0
- package/reference/formats.md +640 -0
- package/reference/json-schema.md +626 -0
- package/reference/json.md +979 -0
- package/reference/modifier.md +276 -0
- package/reference/optics.md +1613 -0
- package/reference/patch.md +631 -0
- package/reference/reflect-transform.md +387 -0
- package/reference/reflect.md +521 -0
- package/reference/registers.md +282 -0
- package/reference/schema-evolution.md +540 -0
- package/reference/schema.md +619 -0
- package/reference/syntax.md +409 -0
- package/reference/type-class-derivation-internals.md +632 -0
- package/reference/typeid.md +900 -0
- package/reference/validation.md +458 -0
- package/scope.md +627 -0
- package/sidebars.js +30 -0
|
@@ -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
|
+
```
|