@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.
@@ -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 |