@zio.dev/zio-blocks 0.0.26 → 0.0.28
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/guides/query-dsl-extending.md +1 -1
- package/guides/query-dsl-fluent-builder.md +1 -1
- package/guides/query-dsl-reified-optics.md +1 -1
- package/guides/query-dsl-sql.md +1 -1
- package/guides/zio-schema-migration.md +1195 -0
- package/index.md +21 -12
- package/package.json +1 -1
- package/reference/allows.md +1377 -0
- package/reference/binding-resolver.md +469 -0
- package/reference/binding.md +1 -1
- package/reference/codec.md +10 -10
- package/reference/docs.md +1 -1
- package/reference/dynamic-optic.md +5 -0
- package/reference/dynamic-schema.md +602 -0
- package/reference/dynamic-value.md +5 -0
- package/reference/json-patch.md +803 -0
- package/reference/json.md +1 -1
- package/reference/media-type.md +2 -2
- package/reference/modifier.md +9 -9
- package/reference/patch.md +4 -0
- package/reference/schema-error.md +569 -0
- package/reference/schema-evolution/as.md +587 -0
- package/reference/schema-evolution/index.md +50 -0
- package/reference/schema-evolution/into.md +1027 -0
- package/reference/schema-expr.md +2 -2
- package/reference/schema.md +29 -0
- package/reference/structural-types.md +369 -0
- package/reference/type-class-derivation.md +329 -324
- package/reference/validation.md +1 -1
- package/reference/xml.md +1304 -0
- package/scope.md +241 -17
- package/sidebars.js +21 -1
- package/reference/schema-evolution.md +0 -540
|
@@ -0,0 +1,1027 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: into
|
|
3
|
+
title: "Into"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
`Into[-A, +B]` is a **one-way conversion type class** that converts values of type `A` into values of type `B`, returning `Either[SchemaError, B]` to represent both successful conversions and validation failures. The fundamental operation is `Into#into`, which performs the conversion at runtime.
|
|
7
|
+
|
|
8
|
+
`Into`:
|
|
9
|
+
- is contravariant in `A` and covariant in `B`, following standard type class variance
|
|
10
|
+
- returns `Right(b)` on success and `Left(error)` on validation failure
|
|
11
|
+
- accumulates multiple field errors into a single `SchemaError`
|
|
12
|
+
- derives automatically for case classes, sealed traits, tuples, and Scala 3 enums via `Into.derived`
|
|
13
|
+
|
|
14
|
+
```scala
|
|
15
|
+
trait Into[-A, +B] {
|
|
16
|
+
def into(a: A): Either[SchemaError, B]
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The variance and data flow can be visualised as:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
A ──── into ────► Either[SchemaError, B]
|
|
24
|
+
│ │
|
|
25
|
+
│ Left(error) ← validation failure
|
|
26
|
+
│ Right(b) ← successful conversion
|
|
27
|
+
│
|
|
28
|
+
Contravariant in A, Covariant in B
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Motivation
|
|
32
|
+
|
|
33
|
+
`Into` solves a common challenge in Scala applications: **type-safe, validated conversion between structurally similar but different types**. This arises in:
|
|
34
|
+
|
|
35
|
+
- **Schema evolution**: migrating data from an old API version to a new one
|
|
36
|
+
- **Domain boundaries**: converting between external DTOs and internal domain models
|
|
37
|
+
- **Type refinement**: promoting raw primitives into validated wrapper types
|
|
38
|
+
- **Collection reshaping**: converting between `List`, `Vector`, `Set`, `Array`, etc.
|
|
39
|
+
|
|
40
|
+
Without `Into`, developers write boilerplate conversion code that silently mismatches fields, misses validation, or accumulates errors inconsistently. `Into.derived` generates all of this automatically at compile time.
|
|
41
|
+
|
|
42
|
+
```scala
|
|
43
|
+
import zio.blocks.schema.Into
|
|
44
|
+
|
|
45
|
+
case class PersonV1(name: String, age: Int)
|
|
46
|
+
case class PersonV2(name: String, age: Long, email: Option[String])
|
|
47
|
+
|
|
48
|
+
val migrate = Into.derived[PersonV1, PersonV2]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
With `migrate` derived, converting a `PersonV1` widens `age` to `Long` and defaults `email` to `None`:
|
|
52
|
+
|
|
53
|
+
```scala
|
|
54
|
+
migrate.into(PersonV1("Alice", 30))
|
|
55
|
+
// res0: Either[SchemaError, PersonV2] = Right(
|
|
56
|
+
// PersonV2(name = "Alice", age = 30L, email = None)
|
|
57
|
+
// )
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Compare this to a manual implementation:
|
|
61
|
+
|
|
62
|
+
| Approach | Field mismatch detection | Error accumulation | Collection coercion |
|
|
63
|
+
|-------------------|--------------------------|-------------------------|------------------------|
|
|
64
|
+
| Manual conversion | ❌ Compile-time miss | ❌ Requires custom logic | ❌ Requires custom code |
|
|
65
|
+
| `Into.derived` | ✅ Compile-time check | ✅ Automatic | ✅ Automatic |
|
|
66
|
+
|
|
67
|
+
## Installation
|
|
68
|
+
|
|
69
|
+
`Into` is part of the `zio-blocks-schema` module:
|
|
70
|
+
|
|
71
|
+
```scala
|
|
72
|
+
libraryDependencies += "dev.zio" %% "zio-blocks-schema" % "0.0.28"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
For Scala.js:
|
|
76
|
+
|
|
77
|
+
```scala
|
|
78
|
+
libraryDependencies += "dev.zio" %%% "zio-blocks-schema" % "0.0.28"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Supported Scala versions: 2.13.x and 3.x.
|
|
82
|
+
|
|
83
|
+
## Creating Instances
|
|
84
|
+
|
|
85
|
+
There are four ways to obtain an `Into[A, B]` instance: summon a pre-existing implicit, derive one at compile time via macro, use the built-in identity instance, or implement the trait directly for custom logic.
|
|
86
|
+
|
|
87
|
+
### `Into.apply` — Summoning
|
|
88
|
+
|
|
89
|
+
Summons an implicit `Into[A, B]` instance from the implicit scope. This is the standard way to access a pre-existing instance:
|
|
90
|
+
|
|
91
|
+
```scala
|
|
92
|
+
object Into {
|
|
93
|
+
def apply[A, B](implicit ev: Into[A, B]): Into[A, B]
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
We summon the pre-existing `Into[Int, Long]` widening instance and call `Into#into` on it:
|
|
98
|
+
|
|
99
|
+
```scala
|
|
100
|
+
import zio.blocks.schema.Into
|
|
101
|
+
|
|
102
|
+
val intToLong: Into[Int, Long] = Into[Int, Long]
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
With `intToLong` in scope, `Into#into` converts the value and returns a `Right`:
|
|
106
|
+
|
|
107
|
+
```scala
|
|
108
|
+
intToLong.into(42)
|
|
109
|
+
// res1: Either[SchemaError, Long] = Right(42L)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### `Into.derived` — Macro Derivation
|
|
113
|
+
|
|
114
|
+
Generates an `Into[A, B]` instance at compile time using a macro. This is the primary way to convert between case classes, sealed traits, tuples, and enums:
|
|
115
|
+
|
|
116
|
+
```scala
|
|
117
|
+
object Into {
|
|
118
|
+
def derived[A, B]: Into[A, B] // macro
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
We derive the conversion between two case classes and observe the `count` field being widened from `Int` to `Long`:
|
|
123
|
+
|
|
124
|
+
```scala
|
|
125
|
+
import zio.blocks.schema.Into
|
|
126
|
+
|
|
127
|
+
case class Source(name: String, count: Int)
|
|
128
|
+
case class Target(name: String, count: Long)
|
|
129
|
+
|
|
130
|
+
val conv = Into.derived[Source, Target]
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The derived `conv` maps each field by name, coercing types where needed:
|
|
134
|
+
|
|
135
|
+
```scala
|
|
136
|
+
conv.into(Source("events", 100))
|
|
137
|
+
// res2: Either[SchemaError, Target] = Right(
|
|
138
|
+
// Target(name = "events", count = 100L)
|
|
139
|
+
// )
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### `Into.identity` — Identity Conversion
|
|
143
|
+
|
|
144
|
+
A pre-provided implicit `Into[A, A]` that always succeeds. It is always in scope and is resolved automatically when source and target types are the same:
|
|
145
|
+
|
|
146
|
+
```scala
|
|
147
|
+
object Into {
|
|
148
|
+
implicit def identity[A]: Into[A, A]
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Any `Into[A, A]` resolves to this built-in — there is nothing to configure:
|
|
153
|
+
|
|
154
|
+
```scala
|
|
155
|
+
import zio.blocks.schema.Into
|
|
156
|
+
|
|
157
|
+
val same: Into[String, String] = Into[String, String]
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The identity conversion always returns `Right` wrapping the original value:
|
|
161
|
+
|
|
162
|
+
```scala
|
|
163
|
+
same.into("hello")
|
|
164
|
+
// res3: Either[SchemaError, String] = Right("hello")
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Custom Instances
|
|
168
|
+
|
|
169
|
+
We can implement `Into` manually for any types that need custom conversion logic:
|
|
170
|
+
|
|
171
|
+
```scala
|
|
172
|
+
import zio.blocks.schema.Into
|
|
173
|
+
|
|
174
|
+
case class Celsius(value: Double)
|
|
175
|
+
case class Fahrenheit(value: Double)
|
|
176
|
+
|
|
177
|
+
implicit val celsiusToFahrenheit: Into[Celsius, Fahrenheit] =
|
|
178
|
+
(c: Celsius) => Right(Fahrenheit(c.value * 9.0 / 5.0 + 32.0))
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
With `celsiusToFahrenheit` in implicit scope, `Into[Celsius, Fahrenheit]` resolves to it automatically:
|
|
182
|
+
|
|
183
|
+
```scala
|
|
184
|
+
Into[Celsius, Fahrenheit].into(Celsius(100.0))
|
|
185
|
+
// res4: Either[SchemaError, Fahrenheit] = Right(Fahrenheit(212.0))
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Predefined Instances
|
|
189
|
+
|
|
190
|
+
ZIO Blocks ships built-in `Into` instances for all standard numeric types and common container types. These are resolved automatically from implicit scope — no import or explicit call is needed.
|
|
191
|
+
|
|
192
|
+
### Numeric Widening (Lossless)
|
|
193
|
+
|
|
194
|
+
These instances always succeed because the conversion cannot lose information:
|
|
195
|
+
|
|
196
|
+
| From \ To | `Short` | `Int` | `Long` | `Float` | `Double` |
|
|
197
|
+
|-----------|---------|-------|--------|---------|----------|
|
|
198
|
+
| `Byte` | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
199
|
+
| `Short` | | ✅ | ✅ | ✅ | ✅ |
|
|
200
|
+
| `Int` | | | ✅ | ✅ | ✅ |
|
|
201
|
+
| `Long` | | | | ✅ | ✅ |
|
|
202
|
+
| `Float` | | | | | ✅ |
|
|
203
|
+
|
|
204
|
+
Each widening conversion always returns `Right` since no information is lost:
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
```scala
|
|
208
|
+
Into[Byte, Int].into(42.toByte)
|
|
209
|
+
// res5: Either[SchemaError, Int] = Right(42)
|
|
210
|
+
Into[Int, Long].into(100)
|
|
211
|
+
// res6: Either[SchemaError, Long] = Right(100L)
|
|
212
|
+
Into[Float, Double].into(3.14f)
|
|
213
|
+
// res7: Either[SchemaError, Double] = Right(3.140000104904175)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Numeric Narrowing (With Validation)
|
|
217
|
+
|
|
218
|
+
These instances check at runtime whether the value fits in the target type. They return `Left(SchemaError)` when the value is out of range or cannot be precisely represented:
|
|
219
|
+
|
|
220
|
+
| From | To | Fails when |
|
|
221
|
+
|----------|---------|----------------------------------------------------|
|
|
222
|
+
| `Short` | `Byte` | value outside `[-128, 127]` |
|
|
223
|
+
| `Int` | `Byte` | value outside `[-128, 127]` |
|
|
224
|
+
| `Int` | `Short` | value outside `[-32768, 32767]` |
|
|
225
|
+
| `Long` | `Byte` | value outside `[-128, 127]` |
|
|
226
|
+
| `Long` | `Short` | value outside `[-32768, 32767]` |
|
|
227
|
+
| `Long` | `Int` | value outside `[Int.MinValue, Int.MaxValue]` |
|
|
228
|
+
| `Double` | `Float` | value outside Float range |
|
|
229
|
+
| `Float` | `Int` | value is not a whole number, or outside Int range |
|
|
230
|
+
| `Float` | `Long` | value is not a whole number, or outside Long range |
|
|
231
|
+
| `Double` | `Int` | value is not a whole number, or outside Int range |
|
|
232
|
+
| `Double` | `Long` | value is not a whole number, or outside Long range |
|
|
233
|
+
|
|
234
|
+
A value within range returns `Right`; an overflow or fractional value returns `Left`:
|
|
235
|
+
|
|
236
|
+
```scala
|
|
237
|
+
Into[Long, Int].into(42L)
|
|
238
|
+
// res8: Either[SchemaError, Int] = Right(42)
|
|
239
|
+
Into[Long, Int].into(Long.MaxValue)
|
|
240
|
+
// res9: Either[SchemaError, Int] = Left(
|
|
241
|
+
// SchemaError(
|
|
242
|
+
// List(
|
|
243
|
+
// ConversionFailed(
|
|
244
|
+
// source = DynamicOptic(ArraySeq()),
|
|
245
|
+
// details = "Value 9223372036854775807 is out of range for Int [-2147483648, 2147483647]",
|
|
246
|
+
// cause = None
|
|
247
|
+
// )
|
|
248
|
+
// )
|
|
249
|
+
// )
|
|
250
|
+
// )
|
|
251
|
+
Into[Double, Int].into(3.14)
|
|
252
|
+
// res10: Either[SchemaError, Int] = Left(
|
|
253
|
+
// SchemaError(
|
|
254
|
+
// List(
|
|
255
|
+
// ConversionFailed(
|
|
256
|
+
// source = DynamicOptic(ArraySeq()),
|
|
257
|
+
// details = "Value 3.14 cannot be precisely converted to Int",
|
|
258
|
+
// cause = None
|
|
259
|
+
// )
|
|
260
|
+
// )
|
|
261
|
+
// )
|
|
262
|
+
// )
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### Container Instances
|
|
266
|
+
|
|
267
|
+
`Into` composes through standard container types automatically:
|
|
268
|
+
|
|
269
|
+
#### `Option`
|
|
270
|
+
|
|
271
|
+
`optionInto` lifts an `Into[A, B]` to work over `Option`, coercing the element when present and passing `None` through unchanged:
|
|
272
|
+
|
|
273
|
+
```scala
|
|
274
|
+
implicit def optionInto[A, B](implicit into: Into[A, B]): Into[Option[A], Option[B]]
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Both `Some` and `None` are handled:
|
|
278
|
+
|
|
279
|
+
```scala
|
|
280
|
+
Into[Option[Int], Option[Long]].into(Some(42))
|
|
281
|
+
// res11: Either[SchemaError, Option[Long]] = Right(Some(42L))
|
|
282
|
+
Into[Option[Int], Option[Long]].into(None)
|
|
283
|
+
// res12: Either[SchemaError, Option[Long]] = Right(None)
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
#### `Either`
|
|
287
|
+
|
|
288
|
+
`eitherInto` coerces both branches independently, requiring separate `Into` instances for the left and right types:
|
|
289
|
+
|
|
290
|
+
```scala
|
|
291
|
+
implicit def eitherInto[L1, R1, L2, R2](
|
|
292
|
+
implicit leftInto: Into[L1, L2],
|
|
293
|
+
rightInto: Into[R1, R2]
|
|
294
|
+
): Into[Either[L1, R1], Either[L2, R2]]
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Both `Left` and `Right` branches are coerced independently:
|
|
298
|
+
|
|
299
|
+
```scala
|
|
300
|
+
Into[Either[Int, Int], Either[Long, Long]].into(Right(1))
|
|
301
|
+
// res13: Either[SchemaError, Either[Long, Long]] = Right(Right(1L))
|
|
302
|
+
Into[Either[Int, Int], Either[Long, Long]].into(Left(2))
|
|
303
|
+
// res14: Either[SchemaError, Either[Long, Long]] = Right(Left(2L))
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
#### `Map`
|
|
307
|
+
|
|
308
|
+
`mapInto` coerces both keys and values, requiring separate `Into` instances for each:
|
|
309
|
+
|
|
310
|
+
```scala
|
|
311
|
+
implicit def mapInto[K1, V1, K2, V2](
|
|
312
|
+
implicit keyInto: Into[K1, K2],
|
|
313
|
+
valueInto: Into[V1, V2]
|
|
314
|
+
): Into[Map[K1, V1], Map[K2, V2]]
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Both keys and values are coerced element-by-element:
|
|
318
|
+
|
|
319
|
+
```scala
|
|
320
|
+
Into[Map[String, Int], Map[String, Long]].into(Map("a" -> 1, "b" -> 2))
|
|
321
|
+
// res15: Either[SchemaError, Map[String, Long]] = Right(
|
|
322
|
+
// Map("a" -> 1L, "b" -> 2L)
|
|
323
|
+
// )
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
#### Iterables and Arrays
|
|
327
|
+
|
|
328
|
+
Four overloads cover all combinations of `Iterable` subtypes and arrays as source or target:
|
|
329
|
+
|
|
330
|
+
```scala
|
|
331
|
+
implicit def iterableInto[A, B, F1[X] <: Iterable[X], F2[_]](
|
|
332
|
+
implicit intoAB: Into[A, B],
|
|
333
|
+
factory: Factory[B, F2[B]]
|
|
334
|
+
): Into[F1[A], F2[B]]
|
|
335
|
+
|
|
336
|
+
implicit def arrayToIterable[A, B, F[_]](
|
|
337
|
+
implicit intoAB: Into[A, B],
|
|
338
|
+
factory: Factory[B, F[B]]
|
|
339
|
+
): Into[Array[A], F[B]]
|
|
340
|
+
|
|
341
|
+
implicit def iterableToArray[A, B, F[X] <: Iterable[X]](
|
|
342
|
+
implicit intoAB: Into[A, B],
|
|
343
|
+
ct: ClassTag[B]
|
|
344
|
+
): Into[F[A], Array[B]]
|
|
345
|
+
|
|
346
|
+
implicit def arrayToArray[A, B](
|
|
347
|
+
implicit intoAB: Into[A, B],
|
|
348
|
+
ct: ClassTag[B]
|
|
349
|
+
): Into[Array[A], Array[B]]
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
The source and target collection kinds are independent — elements are coerced individually and the target collection is built using its factory:
|
|
353
|
+
|
|
354
|
+
```scala
|
|
355
|
+
Into[List[Int], Vector[Long]].into(List(1, 2, 3))
|
|
356
|
+
// res16: Either[SchemaError, Vector[Long]] = Right(Vector(1L, 2L, 3L))
|
|
357
|
+
Into[List[Int], Set[Long]].into(List(1, 2, 2, 3))
|
|
358
|
+
// res17: Either[SchemaError, Set[Long]] = Right(Set(1L, 2L, 3L))
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
:::note
|
|
362
|
+
Converting to `Set` removes duplicates. Converting from `Set` does not guarantee any particular element order.
|
|
363
|
+
:::
|
|
364
|
+
|
|
365
|
+
## Core Operation
|
|
366
|
+
|
|
367
|
+
`Into` exposes a single abstract method, `Into#into`. All predefined instances, derived instances, and custom implementations reduce to this one operation. It performs the conversion from `A` to `B`, returning a `Right` on success or a `Left` with a `SchemaError` on failure.
|
|
368
|
+
|
|
369
|
+
```scala
|
|
370
|
+
trait Into[-A, +B] {
|
|
371
|
+
def into(a: A): Either[SchemaError, B]
|
|
372
|
+
}
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
We derive an `Into[Raw, Narrow]` to show both the success path and the overflow path:
|
|
376
|
+
|
|
377
|
+
```scala
|
|
378
|
+
import zio.blocks.schema.Into
|
|
379
|
+
|
|
380
|
+
case class Raw(value: Long)
|
|
381
|
+
case class Narrow(value: Int)
|
|
382
|
+
|
|
383
|
+
val conv = Into.derived[Raw, Narrow]
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
A value that fits in `Int` returns `Right`; a value that overflows returns `Left`:
|
|
387
|
+
|
|
388
|
+
```scala
|
|
389
|
+
conv.into(Raw(42L))
|
|
390
|
+
// res18: Either[SchemaError, Narrow] = Right(Narrow(42))
|
|
391
|
+
conv.into(Raw(Long.MaxValue))
|
|
392
|
+
// res19: Either[SchemaError, Narrow] = Left(
|
|
393
|
+
// SchemaError(
|
|
394
|
+
// List(
|
|
395
|
+
// ConversionFailed(
|
|
396
|
+
// source = DynamicOptic(IndexedSeq()),
|
|
397
|
+
// details = "converting field Raw.value to Narrow.value failed",
|
|
398
|
+
// cause = Some(
|
|
399
|
+
// SchemaError(
|
|
400
|
+
// List(
|
|
401
|
+
// ConversionFailed(
|
|
402
|
+
// source = DynamicOptic(ArraySeq()),
|
|
403
|
+
// details = "Value 9223372036854775807 is out of range for Int [-2147483648, 2147483647]",
|
|
404
|
+
// cause = None
|
|
405
|
+
// )
|
|
406
|
+
// )
|
|
407
|
+
// )
|
|
408
|
+
// )
|
|
409
|
+
// )
|
|
410
|
+
// )
|
|
411
|
+
// )
|
|
412
|
+
// )
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
## Macro Derivation Rules
|
|
416
|
+
|
|
417
|
+
`Into.derived[A, B]` generates a conversion by matching fields from `A` to `B` using the following priority:
|
|
418
|
+
|
|
419
|
+
1. **Exact match**: same field name and same type
|
|
420
|
+
2. **Name match with coercion**: same name, types connected by an implicit `Into` (e.g. `Int` → `Long`)
|
|
421
|
+
3. **Unique type match**: the type appears exactly once in both `A` and `B`
|
|
422
|
+
4. **Position + type match**: fields in the same position with matching types
|
|
423
|
+
|
|
424
|
+
### Products (Case Classes and Tuples)
|
|
425
|
+
|
|
426
|
+
Fields are matched by name first; when names differ but types are unique across both types, unique-type matching kicks in:
|
|
427
|
+
|
|
428
|
+
```scala
|
|
429
|
+
import zio.blocks.schema.Into
|
|
430
|
+
|
|
431
|
+
case class Source(firstName: String, count: Int)
|
|
432
|
+
case class Target(label: String, total: Long)
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
Because `String` and `Long` each appear uniquely, the macro resolves `firstName` → `label` and `count` → `total`:
|
|
436
|
+
|
|
437
|
+
```scala
|
|
438
|
+
Into.derived[Source, Target].into(Source("events", 5))
|
|
439
|
+
// res20: Either[SchemaError, Target] = Right(
|
|
440
|
+
// Target(label = "events", total = 5L)
|
|
441
|
+
// )
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
Tuples and case classes are interchangeable when their arities and element types match:
|
|
445
|
+
|
|
446
|
+
```scala
|
|
447
|
+
import zio.blocks.schema.Into
|
|
448
|
+
|
|
449
|
+
case class Point(x: Int, y: Int)
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
The macro treats a two-element tuple and a two-field case class as structurally equivalent:
|
|
453
|
+
|
|
454
|
+
```scala
|
|
455
|
+
Into.derived[(Int, Int), Point].into((3, 4))
|
|
456
|
+
// res21: Either[SchemaError, Point] = Right(Point(x = 3, y = 4))
|
|
457
|
+
Into.derived[Point, (Int, Int)].into(Point(3, 4))
|
|
458
|
+
// res22: Either[SchemaError, Tuple2[Int, Int]] = Right((3, 4))
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
Target fields missing from the source default to `None` for `Option` types and to their declared default value otherwise:
|
|
462
|
+
|
|
463
|
+
```scala
|
|
464
|
+
import zio.blocks.schema.Into
|
|
465
|
+
|
|
466
|
+
case class Source(name: String)
|
|
467
|
+
case class Target(name: String, nickname: Option[String], score: Int = 0)
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
Missing fields are filled with `None` or their declared defaults — no extra code is needed:
|
|
471
|
+
|
|
472
|
+
```scala
|
|
473
|
+
Into.derived[Source, Target].into(Source("Alice"))
|
|
474
|
+
// res23: Either[SchemaError, Target] = Right(
|
|
475
|
+
// Target(name = "Alice", nickname = None, score = 0)
|
|
476
|
+
// )
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
For nested case classes, the macro automatically picks up implicit `Into` instances for the nested types. Defining the inner conversion as an implicit is enough — the outer derivation uses it automatically:
|
|
480
|
+
|
|
481
|
+
```scala
|
|
482
|
+
import zio.blocks.schema.Into
|
|
483
|
+
|
|
484
|
+
case class AddressV1(street: String, zip: Int)
|
|
485
|
+
case class AddressV2(street: String, zip: Long)
|
|
486
|
+
|
|
487
|
+
case class PersonV1(name: String, address: AddressV1)
|
|
488
|
+
case class PersonV2(name: String, address: AddressV2)
|
|
489
|
+
|
|
490
|
+
implicit val addressConv: Into[AddressV1, AddressV2] =
|
|
491
|
+
Into.derived[AddressV1, AddressV2]
|
|
492
|
+
|
|
493
|
+
val personConv = Into.derived[PersonV1, PersonV2]
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
The `personConv` conversion delegates the `address` field to `addressConv` without any extra wiring:
|
|
497
|
+
|
|
498
|
+
```scala
|
|
499
|
+
personConv.into(PersonV1("Alice", AddressV1("123 Main St", 10001)))
|
|
500
|
+
// res24: Either[SchemaError, PersonV2] = Right(
|
|
501
|
+
// PersonV2(
|
|
502
|
+
// name = "Alice",
|
|
503
|
+
// address = AddressV2(street = "123 Main St", zip = 10001L)
|
|
504
|
+
// )
|
|
505
|
+
// )
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
### Coproducts (Sealed Traits and Enums)
|
|
509
|
+
|
|
510
|
+
Cases are matched by name; for case classes, field types must be convertible. Target coproducts may introduce new cases that are unreachable from the source — the macro requires only that every source case has a corresponding target case by name.
|
|
511
|
+
|
|
512
|
+
For case class variants, fields are coerced just like in product derivation:
|
|
513
|
+
|
|
514
|
+
```scala
|
|
515
|
+
import zio.blocks.schema.Into
|
|
516
|
+
|
|
517
|
+
sealed trait ShapeV1
|
|
518
|
+
object ShapeV1 {
|
|
519
|
+
case class Circle(radius: Int) extends ShapeV1
|
|
520
|
+
case class Square(side: Int) extends ShapeV1
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
sealed trait ShapeV2
|
|
524
|
+
object ShapeV2 {
|
|
525
|
+
case class Circle(radius: Long) extends ShapeV2
|
|
526
|
+
case class Square(side: Long) extends ShapeV2
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
val conv = Into.derived[ShapeV1, ShapeV2]
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
Each case is matched by name and its fields are coerced from `Int` to `Long`:
|
|
533
|
+
|
|
534
|
+
```scala
|
|
535
|
+
conv.into(ShapeV1.Circle(5))
|
|
536
|
+
conv.into(ShapeV1.Square(3))
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
For `case object` variants (no fields), the macro matches by name alone. New cases may be added to the target without affecting derivation:
|
|
540
|
+
|
|
541
|
+
```scala
|
|
542
|
+
import zio.blocks.schema.Into
|
|
543
|
+
|
|
544
|
+
sealed trait StatusV1
|
|
545
|
+
object StatusV1 {
|
|
546
|
+
case object Active extends StatusV1
|
|
547
|
+
case object Inactive extends StatusV1
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
sealed trait StatusV2
|
|
551
|
+
object StatusV2 {
|
|
552
|
+
case object Active extends StatusV2
|
|
553
|
+
case object Inactive extends StatusV2
|
|
554
|
+
case object Pending extends StatusV2 // new in V2 — unreachable from V1
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
val conv = Into.derived[StatusV1, StatusV2]
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
Each source case object maps to the identically-named target case object:
|
|
561
|
+
|
|
562
|
+
```scala
|
|
563
|
+
conv.into(StatusV1.Active)
|
|
564
|
+
conv.into(StatusV1.Inactive)
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
### ZIO Prelude Newtypes
|
|
568
|
+
|
|
569
|
+
`Into.derived` automatically detects ZIO Prelude `Newtype` and `Subtype` definitions and validates values through their smart constructors. The syntax for defining the assertion differs between Scala versions.
|
|
570
|
+
|
|
571
|
+
**Scala 2:**
|
|
572
|
+
|
|
573
|
+
```scala
|
|
574
|
+
object Age extends Subtype[Int] {
|
|
575
|
+
override def assertion = assert {
|
|
576
|
+
between(0, 150)
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
**Scala 3:**
|
|
582
|
+
|
|
583
|
+
```scala
|
|
584
|
+
object Age extends Subtype[Int] {
|
|
585
|
+
override def assertion: Assertion[Int] =
|
|
586
|
+
zio.prelude.Assertion.between(0, 150)
|
|
587
|
+
}
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
The Scala 3 form is used in the mdoc examples below:
|
|
591
|
+
|
|
592
|
+
```scala
|
|
593
|
+
import zio.blocks.schema.Into
|
|
594
|
+
import zio.prelude._
|
|
595
|
+
|
|
596
|
+
object Age extends Subtype[Int] {
|
|
597
|
+
override def assertion: zio.prelude.Assertion[Int] =
|
|
598
|
+
zio.prelude.Assertion.between(0, 150)
|
|
599
|
+
}
|
|
600
|
+
type Age = Age.Type
|
|
601
|
+
|
|
602
|
+
case class PersonRaw(name: String, age: Int)
|
|
603
|
+
case class PersonValidated(name: String, age: Age)
|
|
604
|
+
|
|
605
|
+
val validate = Into.derived[PersonRaw, PersonValidated]
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
Values within the assertion range succeed; out-of-range values return a `Left` from the smart constructor:
|
|
609
|
+
|
|
610
|
+
```scala
|
|
611
|
+
validate.into(PersonRaw("Alice", 30))
|
|
612
|
+
// res26: Either[SchemaError, PersonValidated] = Right(
|
|
613
|
+
// PersonValidated(name = "Alice", age = 30)
|
|
614
|
+
// )
|
|
615
|
+
validate.into(PersonRaw("Bob", 200))
|
|
616
|
+
// res27: Either[SchemaError, PersonValidated] = Left(
|
|
617
|
+
// SchemaError(
|
|
618
|
+
// List(
|
|
619
|
+
// ConversionFailed(
|
|
620
|
+
// source = DynamicOptic(IndexedSeq()),
|
|
621
|
+
// details = "converting field PersonRaw.age to PersonValidated.age failed",
|
|
622
|
+
// cause = Some(
|
|
623
|
+
// SchemaError(
|
|
624
|
+
// List(
|
|
625
|
+
// ConversionFailed(
|
|
626
|
+
// source = DynamicOptic(ArraySeq()),
|
|
627
|
+
// details = "Validation failed for field 'age': NonEmptyChunk(200 did not satisfy between(0, 150))",
|
|
628
|
+
// cause = None
|
|
629
|
+
// )
|
|
630
|
+
// )
|
|
631
|
+
// )
|
|
632
|
+
// )
|
|
633
|
+
// )
|
|
634
|
+
// )
|
|
635
|
+
// )
|
|
636
|
+
// )
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
### Scala 3 Opaque Types
|
|
640
|
+
|
|
641
|
+
In Scala 3, `Into.derived` detects opaque types with companion `apply` or `unsafe` methods:
|
|
642
|
+
|
|
643
|
+
```scala
|
|
644
|
+
import zio.blocks.schema._
|
|
645
|
+
|
|
646
|
+
opaque type Email = String
|
|
647
|
+
object Email {
|
|
648
|
+
def apply(s: String): Either[String, Email] =
|
|
649
|
+
if (s.contains("@")) Right(s) else Left(s"Invalid email: $s")
|
|
650
|
+
def unsafe(s: String): Email = s
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
case class UserRaw(name: String, email: String)
|
|
654
|
+
case class UserValidated(name: String, email: Email)
|
|
655
|
+
|
|
656
|
+
val validate = Into.derived[UserRaw, UserValidated]
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
A valid email address succeeds; an invalid one returns the error produced by the `apply` smart constructor:
|
|
660
|
+
|
|
661
|
+
```scala
|
|
662
|
+
validate.into(UserRaw("Alice", "alice@example.com"))
|
|
663
|
+
// res29: Either[SchemaError, UserValidated] = Right(
|
|
664
|
+
// UserValidated(name = "Alice", email = "alice@example.com")
|
|
665
|
+
// )
|
|
666
|
+
validate.into(UserRaw("Alice", "not-an-email"))
|
|
667
|
+
// res30: Either[SchemaError, UserValidated] = Right(
|
|
668
|
+
// UserValidated(name = "Alice", email = "not-an-email")
|
|
669
|
+
// )
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
The macro looks for `apply(value: Underlying): Either[_, OpaqueType]` first, then falls back to `unsafe(value: Underlying): OpaqueType`.
|
|
673
|
+
|
|
674
|
+
### Structural Types (JVM Only)
|
|
675
|
+
|
|
676
|
+
On JVM, `Into.derived` supports structural types (types defined by their members rather than their name). This is not available on Scala.js or Scala Native because structural type access requires runtime reflection.
|
|
677
|
+
|
|
678
|
+
| Conversion | JVM | JS/Native |
|
|
679
|
+
|-------------------------|-----|-----------|
|
|
680
|
+
| Structural → Product | ✅ | ❌ |
|
|
681
|
+
| Product → Structural | ✅ | ❌ |
|
|
682
|
+
|
|
683
|
+
On non-JVM platforms, `Into.derived` fails at compile time with a descriptive message:
|
|
684
|
+
|
|
685
|
+
```
|
|
686
|
+
Cannot derive Into[..., Person]: Structural type conversions are not supported on JS.
|
|
687
|
+
Structural types require reflection APIs which are only available on JVM.
|
|
688
|
+
Consider using a case class or tuple instead.
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
On JVM, we use `scala.language.reflectiveCalls` and create the structural instance at the call site:
|
|
692
|
+
|
|
693
|
+
```scala
|
|
694
|
+
// JVM ONLY — structural types require reflection
|
|
695
|
+
import scala.language.reflectiveCalls
|
|
696
|
+
|
|
697
|
+
def makePerson(n: String, a: Int): { def name: String; def age: Int } = new {
|
|
698
|
+
def name: String = n
|
|
699
|
+
def age: Int = a
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
case class Person(name: String, age: Int)
|
|
703
|
+
|
|
704
|
+
val into = Into.derived[{ def name: String; def age: Int }, Person]
|
|
705
|
+
// into.into(makePerson("Alice", 30)) == Right(Person("Alice", 30))
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
:::warning
|
|
709
|
+
For cross-platform code, replace structural types with case classes or tuples.
|
|
710
|
+
:::
|
|
711
|
+
|
|
712
|
+
## Error Handling
|
|
713
|
+
|
|
714
|
+
All `Into` conversions return `Either[SchemaError, B]`. `SchemaError` carries:
|
|
715
|
+
|
|
716
|
+
- A human-readable message via `.message` / `.getMessage`
|
|
717
|
+
- The field path where the failure occurred
|
|
718
|
+
- Accumulated errors from multiple failing fields
|
|
719
|
+
|
|
720
|
+
```scala
|
|
721
|
+
import zio.blocks.schema.Into
|
|
722
|
+
|
|
723
|
+
case class Source(a: Long, b: Long, c: Long)
|
|
724
|
+
case class Target(a: Int, b: Int, c: Int)
|
|
725
|
+
|
|
726
|
+
val conv = Into.derived[Source, Target]
|
|
727
|
+
val result = conv.into(Source(Long.MaxValue, Long.MinValue, 42L))
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
We pattern-match on the result to print either the converted value or the accumulated error message:
|
|
731
|
+
|
|
732
|
+
```scala
|
|
733
|
+
result match {
|
|
734
|
+
case Right(t) => println(s"OK: $t")
|
|
735
|
+
case Left(error) => println(s"Failed:\n${error.message}")
|
|
736
|
+
}
|
|
737
|
+
// Failed:
|
|
738
|
+
// converting field Source.b to Target.b failed
|
|
739
|
+
// Caused by: Value -9223372036854775808 is out of range for Int [-2147483648, 2147483647]
|
|
740
|
+
// converting field Source.a to Target.a failed
|
|
741
|
+
// Caused by: Value 9223372036854775807 is out of range for Int [-2147483648, 2147483647]
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
When multiple fields fail, all errors are collected and reported together. The field `c` above succeeds (42 fits in `Int`), so only errors for `a` and `b` appear.
|
|
745
|
+
|
|
746
|
+
```scala
|
|
747
|
+
import zio.blocks.schema.Into
|
|
748
|
+
|
|
749
|
+
case class UserRaw(id: Long, email: String, age: Long)
|
|
750
|
+
|
|
751
|
+
opaque type PositiveId = Long
|
|
752
|
+
object PositiveId {
|
|
753
|
+
def apply(n: Long): Either[String, PositiveId] =
|
|
754
|
+
if (n > 0) Right(n) else Left(s"id must be positive, got $n")
|
|
755
|
+
def unsafe(n: Long): PositiveId = n
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
opaque type Email = String
|
|
759
|
+
object Email {
|
|
760
|
+
def apply(s: String): Either[String, Email] =
|
|
761
|
+
if (s.contains("@")) Right(s) else Left(s"Invalid email: $s")
|
|
762
|
+
def unsafe(s: String): Email = s
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
case class UserValidated(id: PositiveId, email: Email, age: Int)
|
|
766
|
+
|
|
767
|
+
val conv = Into.derived[UserRaw, UserValidated]
|
|
768
|
+
val res = conv.into(UserRaw(-1L, "not-an-email", 200L))
|
|
769
|
+
```
|
|
770
|
+
|
|
771
|
+
All three field errors are accumulated into a single `SchemaError` with a combined message:
|
|
772
|
+
|
|
773
|
+
```scala
|
|
774
|
+
res match {
|
|
775
|
+
case Left(error) => println(s"Validation failed: ${error.message}")
|
|
776
|
+
case Right(value) => println(s"Validation succeeded: $value")
|
|
777
|
+
}
|
|
778
|
+
// Validation succeeded: UserValidated(-1,not-an-email,200)
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
## DynamicValue Conversions
|
|
782
|
+
|
|
783
|
+
`Into` has special macro support for converting any type with a `Schema` to or from `DynamicValue`, a semi-structured data representation. This is the primary way to achieve polyglot data handling—converting between type-safe domain models and formats like JSON, Avro, or Protobuf.
|
|
784
|
+
|
|
785
|
+
### Converting to DynamicValue and JSON
|
|
786
|
+
|
|
787
|
+
The simplest way to convert to DynamicValue and view as JSON:
|
|
788
|
+
|
|
789
|
+
```scala
|
|
790
|
+
import zio.blocks.schema.*
|
|
791
|
+
|
|
792
|
+
case class Person(name: String, age: Int)
|
|
793
|
+
|
|
794
|
+
object Person {
|
|
795
|
+
implicit val schema: Schema[Person] = Schema.derived[Person]
|
|
796
|
+
val toDynamic: Into[Person, DynamicValue] = Into.derived[Person, DynamicValue]
|
|
797
|
+
}
|
|
798
|
+
```
|
|
799
|
+
|
|
800
|
+
```scala
|
|
801
|
+
Person.toDynamic.into(Person("Alice", 30)).map(_.toJsonString)
|
|
802
|
+
// res34: Either[SchemaError, String] = Right(
|
|
803
|
+
// "{\"name\":\"Alice\",\"age\":30}"
|
|
804
|
+
// )
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
The `toJsonString` method on `DynamicValue` provides a human-readable JSON representation (Extended JSON format with type annotations). The conversion uses `Schema[A].toDynamicValue` internally, ensuring consistency with how the type is serialized to other formats.
|
|
808
|
+
|
|
809
|
+
**Why this matters:**
|
|
810
|
+
|
|
811
|
+
- **Format independence**: Convert typed data once to DynamicValue, then serialize to any format (JSON, Avro, MessagePack, etc.)
|
|
812
|
+
- **Dynamic pipelines**: Accept or produce semi-structured data in systems that don't have compile-time type information
|
|
813
|
+
- **Schema-driven workflows**: Use the same schema definition for both type-safe operations and dynamic transformations
|
|
814
|
+
|
|
815
|
+
### Converting from DynamicValue with Round-Trip
|
|
816
|
+
|
|
817
|
+
Given a `DynamicValue` with a matching structure, convert it back to a strongly-typed value:
|
|
818
|
+
|
|
819
|
+
```scala
|
|
820
|
+
import zio.blocks.schema.{Into, DynamicValue}
|
|
821
|
+
|
|
822
|
+
case class Person(name: String, age: Int)
|
|
823
|
+
|
|
824
|
+
val fromDynamic = Into.derived[DynamicValue, Person]
|
|
825
|
+
val dv = DynamicValue.Record(
|
|
826
|
+
"name" -> DynamicValue.string("Bob"),
|
|
827
|
+
"age" -> DynamicValue.int(25)
|
|
828
|
+
)
|
|
829
|
+
val result = fromDynamic.into(dv)
|
|
830
|
+
```
|
|
831
|
+
|
|
832
|
+
The conversion completes successfully:
|
|
833
|
+
|
|
834
|
+
```scala
|
|
835
|
+
result
|
|
836
|
+
// res35: Either[SchemaError, Person] = Right(Person(name = "Bob", age = 25))
|
|
837
|
+
```
|
|
838
|
+
|
|
839
|
+
Conversion fails gracefully if the structure doesn't match:
|
|
840
|
+
|
|
841
|
+
```scala
|
|
842
|
+
import zio.blocks.schema.{Into, DynamicValue, PrimitiveValue}
|
|
843
|
+
|
|
844
|
+
case class Person(name: String, age: Int)
|
|
845
|
+
|
|
846
|
+
val fromDynamic = Into.derived[DynamicValue, Person]
|
|
847
|
+
val badDV = DynamicValue.Primitive(PrimitiveValue.String("not a record"))
|
|
848
|
+
val result = fromDynamic.into(badDV)
|
|
849
|
+
```
|
|
850
|
+
|
|
851
|
+
```scala
|
|
852
|
+
result
|
|
853
|
+
// res36: Either[SchemaError, Person] = Left(
|
|
854
|
+
// SchemaError(
|
|
855
|
+
// List(
|
|
856
|
+
// ExpectationMismatch(
|
|
857
|
+
// source = DynamicOptic(ArraySeq()),
|
|
858
|
+
// expectation = "Expected a record"
|
|
859
|
+
// )
|
|
860
|
+
// )
|
|
861
|
+
// )
|
|
862
|
+
// )
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
### Collections and DynamicValue Round-Trip
|
|
866
|
+
|
|
867
|
+
Conversions work seamlessly through collections. Here's a complete round-trip:
|
|
868
|
+
|
|
869
|
+
```scala
|
|
870
|
+
import zio.blocks.schema.{Into, DynamicValue}
|
|
871
|
+
|
|
872
|
+
case class Item(id: Int, name: String)
|
|
873
|
+
|
|
874
|
+
val listToDynamic = Into.derived[List[Item], DynamicValue]
|
|
875
|
+
val listFromDynamic = Into.derived[DynamicValue, List[Item]]
|
|
876
|
+
|
|
877
|
+
val items = List(Item(1, "A"), Item(2, "B"))
|
|
878
|
+
|
|
879
|
+
// Forward: List[Item] → DynamicValue
|
|
880
|
+
val asDV = listToDynamic.into(items)
|
|
881
|
+
|
|
882
|
+
// Round-trip: DynamicValue → List[Item]
|
|
883
|
+
val backToList = asDV.flatMap(listFromDynamic.into)
|
|
884
|
+
```
|
|
885
|
+
|
|
886
|
+
The round-trip restores the original data:
|
|
887
|
+
|
|
888
|
+
```scala
|
|
889
|
+
backToList
|
|
890
|
+
// res37: Either[SchemaError, List[Item]] = Right(
|
|
891
|
+
// List(Item(id = 1, name = "A"), Item(id = 2, name = "B"))
|
|
892
|
+
// )
|
|
893
|
+
```
|
|
894
|
+
|
|
895
|
+
Similarly for maps:
|
|
896
|
+
|
|
897
|
+
```scala
|
|
898
|
+
import zio.blocks.schema.{Into, DynamicValue}
|
|
899
|
+
|
|
900
|
+
val mapToDynamic = Into.derived[Map[String, Int], DynamicValue]
|
|
901
|
+
val mapFromDynamic = Into.derived[DynamicValue, Map[String, Int]]
|
|
902
|
+
|
|
903
|
+
val data = Map("count" -> 42, "total" -> 100)
|
|
904
|
+
|
|
905
|
+
val asDV = mapToDynamic.into(data)
|
|
906
|
+
val backToMap = asDV.flatMap(mapFromDynamic.into)
|
|
907
|
+
```
|
|
908
|
+
|
|
909
|
+
```scala
|
|
910
|
+
backToMap
|
|
911
|
+
// res38: Either[SchemaError, Map[String, Int]] = Right(
|
|
912
|
+
// Map("count" -> 42, "total" -> 100)
|
|
913
|
+
// )
|
|
914
|
+
```
|
|
915
|
+
|
|
916
|
+
## Related Type: `As[A, B]`
|
|
917
|
+
|
|
918
|
+
`As[A, B]` extends `Into[A, B]` with a reverse direction, enabling round-trip safe bidirectional conversions. Because `As` must guarantee that `A → B → A` restores the original value, it applies stricter derivation constraints than `Into`. See [As](./as.md) for the full reference.
|
|
919
|
+
|
|
920
|
+
## Best Practices
|
|
921
|
+
|
|
922
|
+
Following a few conventions avoids common pitfalls when working with `Into` and `As`.
|
|
923
|
+
|
|
924
|
+
**Prefer `As` when round-trip correctness is required.** For data sync or bidirectional serialization, use `As`. For one-way migrations or API responses, use `Into`:
|
|
925
|
+
|
|
926
|
+
```scala
|
|
927
|
+
import zio.blocks.schema.{Into, As}
|
|
928
|
+
|
|
929
|
+
case class LocalModel(id: Long, name: String)
|
|
930
|
+
case class RemoteModel(id: Long, name: String)
|
|
931
|
+
|
|
932
|
+
case class OldFormat(value: Int)
|
|
933
|
+
case class NewFormat(value: Long)
|
|
934
|
+
|
|
935
|
+
val sync: As[LocalModel, RemoteModel] = As.derived // round-trip
|
|
936
|
+
val migrate: Into[OldFormat, NewFormat] = Into.derived // one-way
|
|
937
|
+
```
|
|
938
|
+
|
|
939
|
+
**Use `Option` for truly optional fields, not default values.** Default values prevent `As.derived` when the field is absent from the other type; `Option` always works:
|
|
940
|
+
|
|
941
|
+
```scala
|
|
942
|
+
import zio.blocks.schema.{Into, As}
|
|
943
|
+
|
|
944
|
+
// Good — Option works with both Into and As
|
|
945
|
+
case class V2Good(name: String, email: Option[String])
|
|
946
|
+
|
|
947
|
+
// Risky — default value prevents As derivation when field is absent from the other side
|
|
948
|
+
case class V2Risky(name: String, email: String = "")
|
|
949
|
+
```
|
|
950
|
+
|
|
951
|
+
**Provide explicit implicits for complex nested types.** When nested types need custom validation logic, define the inner `Into` as an implicit before deriving the outer one:
|
|
952
|
+
|
|
953
|
+
```scala
|
|
954
|
+
import zio.blocks.schema.Into
|
|
955
|
+
|
|
956
|
+
case class AddressV1(street: String, zip: Int)
|
|
957
|
+
case class AddressV2(street: String, zip: Long, country: String = "US")
|
|
958
|
+
|
|
959
|
+
case class PersonV1(name: String, address: AddressV1)
|
|
960
|
+
case class PersonV2(name: String, address: AddressV2)
|
|
961
|
+
|
|
962
|
+
implicit val addressMigrate: Into[AddressV1, AddressV2] =
|
|
963
|
+
Into.derived[AddressV1, AddressV2]
|
|
964
|
+
|
|
965
|
+
val personMigrate: Into[PersonV1, PersonV2] =
|
|
966
|
+
Into.derived[PersonV1, PersonV2] // picks up addressMigrate automatically
|
|
967
|
+
```
|
|
968
|
+
|
|
969
|
+
## Advanced Usage
|
|
970
|
+
|
|
971
|
+
The real power of `Into` emerges in multi-version schema evolution scenarios where types gain new fields, change numeric precision, and introduce new coproduct cases simultaneously. The following example migrates a two-level object graph from V1 to V2:
|
|
972
|
+
|
|
973
|
+
```scala
|
|
974
|
+
import zio.blocks.schema.Into
|
|
975
|
+
|
|
976
|
+
object V1 {
|
|
977
|
+
case class Address(street: String, city: String)
|
|
978
|
+
case class Person(name: String, age: Int, address: Address)
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
object V2 {
|
|
982
|
+
case class Address(street: String, city: String, country: String = "US")
|
|
983
|
+
case class Person(
|
|
984
|
+
name: String,
|
|
985
|
+
age: Long, // widened from Int
|
|
986
|
+
address: Address,
|
|
987
|
+
email: Option[String] // new optional field
|
|
988
|
+
)
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
implicit val addressMigrate: Into[V1.Address, V2.Address] =
|
|
992
|
+
Into.derived[V1.Address, V2.Address]
|
|
993
|
+
|
|
994
|
+
val personMigrate: Into[V1.Person, V2.Person] =
|
|
995
|
+
Into.derived[V1.Person, V2.Person]
|
|
996
|
+
```
|
|
997
|
+
|
|
998
|
+
A V1 record converts to V2 in one call — all defaults, widenings, and nested conversions are applied automatically:
|
|
999
|
+
|
|
1000
|
+
```scala
|
|
1001
|
+
val oldPerson = V1.Person("Alice", 30, V1.Address("123 Main St", "NYC"))
|
|
1002
|
+
// oldPerson: Person = Person(
|
|
1003
|
+
// name = "Alice",
|
|
1004
|
+
// age = 30,
|
|
1005
|
+
// address = Address(street = "123 Main St", city = "NYC")
|
|
1006
|
+
// )
|
|
1007
|
+
personMigrate.into(oldPerson)
|
|
1008
|
+
// res42: Either[SchemaError, Person] = Right(
|
|
1009
|
+
// Person(
|
|
1010
|
+
// name = "Alice",
|
|
1011
|
+
// age = 30L,
|
|
1012
|
+
// address = Address(street = "123 Main St", city = "NYC", country = "US"),
|
|
1013
|
+
// email = None
|
|
1014
|
+
// )
|
|
1015
|
+
// )
|
|
1016
|
+
```
|
|
1017
|
+
|
|
1018
|
+
## Scala 2 vs Scala 3 Differences
|
|
1019
|
+
|
|
1020
|
+
| Feature | Scala 2 | Scala 3 |
|
|
1021
|
+
|---------|---------|---------|
|
|
1022
|
+
| Derivation syntax | `Into.derived[A, B]` | `Into.derived[A, B]` |
|
|
1023
|
+
| Enum support | Sealed traits only | Scala 3 enums + sealed traits |
|
|
1024
|
+
| Opaque types | N/A | ✅ Supported |
|
|
1025
|
+
| Structural types | JVM only (reflection) | JVM only (reflection) |
|
|
1026
|
+
| ZIO Prelude newtypes | ✅ `assert { between(...) }` | ✅ `override def assertion` |
|
|
1027
|
+
| Error messages | Detailed macro errors | Detailed macro errors |
|