@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,587 @@
1
+ ---
2
+ id: as
3
+ title: "As"
4
+ ---
5
+
6
+ `As[A, B]` is a **bidirectional conversion type class** that extends `Into[A, B]` with a reverse direction. In addition to converting `A → B` via `As#into`, it also converts `B → A` via `As#from`, providing a round-trip guarantee.
7
+
8
+ `As`:
9
+ - extends `Into[A, B]`, so every `As` can be used wherever an `Into` is expected
10
+ - returns `Right(b)` or `Right(a)` on success and `Left(error)` on validation failure in both directions
11
+ - derives automatically for case classes, sealed traits, tuples, and Scala 3 enums via `As.derived`
12
+ - enforces stricter derivation constraints than `Into` to guarantee that `A → B → A` always restores the original value
13
+
14
+ ```scala
15
+ trait As[A, B] extends Into[A, B] {
16
+ def from(input: B): Either[SchemaError, A]
17
+ def reverse: As[B, A]
18
+ }
19
+ ```
20
+
21
+ The bidirectional data flow looks like this:
22
+
23
+ ```
24
+ ┌──────────────────────────────────────────────────┐
25
+ │ As[A, B] │
26
+ │ │
27
+ │ into(a: A) ──────────────────────► B │
28
+ │ │
29
+ │ from(b: B) ◄────────────────────── B │
30
+ │ │
31
+ │ reverse: As[B, A] (flips directions) │
32
+ └──────────────────────────────────────────────────┘
33
+ ```
34
+
35
+ `As` is the right choice when the conversion must be safe to run in both directions — for example when synchronising data between a local model and a remote representation, or when migrating a database schema that must remain rollback-capable.
36
+
37
+ ## Installation
38
+
39
+ `As` is part of `zio-blocks-schema`:
40
+
41
+ ```scala
42
+ libraryDependencies += "dev.zio" %% "zio-blocks-schema" % "0.0.28"
43
+ ```
44
+
45
+ For Scala.js and Scala Native, use `%%%`:
46
+
47
+ ```scala
48
+ libraryDependencies += "dev.zio" %%% "zio-blocks-schema" % "0.0.28"
49
+ ```
50
+
51
+ Supported Scala versions: 2.13.x and 3.x.
52
+
53
+ ## Creating Instances
54
+
55
+ There are three ways to obtain an `As[A, B]`: construct it from a pair of `Into` instances, derive it automatically with the macro, or summon an implicit already in scope.
56
+
57
+ ### `As.apply` — Manual Construction
58
+
59
+ `As.apply(intoAB, intoBA)` composes two `Into` instances into one `As`:
60
+
61
+ ```scala
62
+ object As {
63
+ def apply[A, B](intoAB: Into[A, B], intoBA: Into[B, A]): As[A, B]
64
+ }
65
+ ```
66
+
67
+ We build an `As[Int, Long]` by supplying both directions explicitly:
68
+
69
+ ```scala
70
+ import zio.blocks.schema.{As, Into, SchemaError}
71
+
72
+ val intoAB: Into[Int, Long] = a => Right(a.toLong)
73
+ val intoBA: Into[Long, Int] = b =>
74
+ if (b >= Int.MinValue && b <= Int.MaxValue) Right(b.toInt)
75
+ else Left(SchemaError.validationFailed("overflow"))
76
+
77
+ val manualAs: As[Int, Long] = As(intoAB, intoBA)
78
+ ```
79
+
80
+ With `manualAs` in scope we can convert in both directions and verify overflow detection:
81
+
82
+ ```scala
83
+ manualAs.into(42)
84
+ // res0: Either[SchemaError, Long] = Right(42L)
85
+ manualAs.from(100L)
86
+ // res1: Either[SchemaError, Int] = Right(100)
87
+ manualAs.from(Long.MaxValue)
88
+ // res2: Either[SchemaError, Int] = Left(
89
+ // SchemaError(
90
+ // List(
91
+ // ConversionFailed(
92
+ // source = DynamicOptic(ArraySeq()),
93
+ // details = "overflow",
94
+ // cause = None
95
+ // )
96
+ // )
97
+ // )
98
+ // )
99
+ ```
100
+
101
+ ### `As.derived` — Macro Derivation
102
+
103
+ `As.derived[A, B]` generates an `As[A, B]` at compile time by deriving both `Into[A, B]` and `Into[B, A]` and running bidirectional compatibility checks:
104
+
105
+ ```scala
106
+ object As {
107
+ def derived[A, B]: As[A, B]
108
+ }
109
+ ```
110
+
111
+ The macro works with case classes, sealed traits, Scala 3 enums, tuples, ZIO Prelude newtypes, Scala 3 opaque types, and structural types (JVM only). We derive an `As` for two case classes with matching fields:
112
+
113
+ ```scala
114
+ import zio.blocks.schema.As
115
+
116
+ case class PersonA(name: String, age: Int)
117
+ case class PersonB(name: String, age: Long)
118
+
119
+ val personAs: As[PersonA, PersonB] = As.derived[PersonA, PersonB]
120
+ ```
121
+
122
+ Both `As#into` and `As#from` are now available, and we can verify that `A → B → A` restores the original value:
123
+
124
+ ```scala
125
+ personAs.into(PersonA("Alice", 30))
126
+ // res3: Either[SchemaError, PersonB] = Right(
127
+ // PersonB(name = "Alice", age = 30L)
128
+ // )
129
+ personAs.from(PersonB("Bob", 25L))
130
+ // res4: Either[SchemaError, PersonA] = Right(PersonA(name = "Bob", age = 25))
131
+ personAs.into(PersonA("Alice", 30)).flatMap(personAs.from)
132
+ // res5: Either[SchemaError, PersonA] = Right(
133
+ // PersonA(name = "Alice", age = 30)
134
+ // )
135
+ ```
136
+
137
+ ### `As.apply[A, B]` — Summoning
138
+
139
+ `As.apply[A, B]` (with no arguments) summons an implicit `As[A, B]` already in scope — the same pattern used by `Into.apply`:
140
+
141
+ ```scala
142
+ object As {
143
+ def apply[A, B](implicit ev: As[A, B]): As[A, B]
144
+ }
145
+ ```
146
+
147
+ This is useful when you want to retrieve a type-class instance by type rather than by variable name:
148
+
149
+ ```scala
150
+ import zio.blocks.schema.As
151
+
152
+ case class Foo(x: Int)
153
+ case class Bar(x: Int)
154
+
155
+ implicit val fooBarAs: As[Foo, Bar] = As.derived[Foo, Bar]
156
+
157
+ // Summon the implicit instance
158
+ val summoned = As[Foo, Bar]
159
+ summoned.into(Foo(1))
160
+ summoned.from(Bar(2))
161
+ ```
162
+
163
+ ## Core Operations
164
+
165
+ `As` exposes three operations: `As#into`, `As#from`, and `As#reverse`.
166
+
167
+ ### `As#into` — Forward Conversion
168
+
169
+ `As#into` is inherited from `Into[A, B]` and converts an `A` into `Either[SchemaError, B]`:
170
+
171
+ ```scala
172
+ trait As[A, B] extends Into[A, B] {
173
+ def into(a: A): Either[SchemaError, B]
174
+ }
175
+ ```
176
+
177
+ ### `As#from` — Reverse Conversion
178
+
179
+ `As#from` is the operation that distinguishes `As` from `Into`. It converts a `B` back to `Either[SchemaError, A]`:
180
+
181
+ ```scala
182
+ trait As[A, B] {
183
+ def from(b: B): Either[SchemaError, A]
184
+ }
185
+ ```
186
+
187
+ We define two simple wrapper types and derive an `As` between them to show both directions:
188
+
189
+ ```scala
190
+ import zio.blocks.schema.As
191
+
192
+ case class IntBox(value: Int)
193
+ case class LongBox(value: Long)
194
+
195
+ val boxAs: As[IntBox, LongBox] = As.derived[IntBox, LongBox]
196
+ ```
197
+
198
+ `As#into` widens the value while `As#from` narrows it, validating that the result fits in the target type:
199
+
200
+ ```scala
201
+ boxAs.into(IntBox(42))
202
+ // res8: Either[SchemaError, LongBox] = Right(LongBox(42L))
203
+ boxAs.from(LongBox(99L))
204
+ // res9: Either[SchemaError, IntBox] = Right(IntBox(99))
205
+ boxAs.from(LongBox(Long.MaxValue))
206
+ // res10: Either[SchemaError, IntBox] = Left(
207
+ // SchemaError(
208
+ // List(
209
+ // ConversionFailed(
210
+ // source = DynamicOptic(IndexedSeq()),
211
+ // details = "converting field LongBox.value to IntBox.value failed",
212
+ // cause = Some(
213
+ // SchemaError(
214
+ // List(
215
+ // ConversionFailed(
216
+ // source = DynamicOptic(ArraySeq()),
217
+ // details = "Value 9223372036854775807 is out of range for Int [-2147483648, 2147483647]",
218
+ // cause = None
219
+ // )
220
+ // )
221
+ // )
222
+ // )
223
+ // )
224
+ // )
225
+ // )
226
+ // )
227
+ ```
228
+
229
+ ### `As#reverse` — Flipping Directions
230
+
231
+ `As#reverse` returns an `As[B, A]` whose `As#into` and `As#from` are swapped:
232
+
233
+ ```scala
234
+ trait As[A, B] {
235
+ def reverse: As[B, A]
236
+ }
237
+ ```
238
+
239
+ `As#reverse` creates a new `As` without touching the original:
240
+
241
+ ```scala
242
+ val revAs: As[LongBox, IntBox] = boxAs.reverse
243
+ // revAs: As[LongBox, IntBox] = zio.blocks.schema.As$$anon$1@15f56a1f
244
+
245
+ revAs.into(LongBox(5L))
246
+ // res11: Either[SchemaError, IntBox] = Right(IntBox(5))
247
+ revAs.from(IntBox(10))
248
+ // res12: Either[SchemaError, LongBox] = Right(LongBox(10L))
249
+ ```
250
+
251
+ ## Using `As` as `Into`
252
+
253
+ Because `As[A, B]` extends `Into[A, B]`, any `As` instance can be passed wherever an `Into` is expected — with no casts or wrapping needed.
254
+
255
+ We write a generic migration helper that requires only an `Into`, then pass an `As` directly:
256
+
257
+ ```scala
258
+ import zio.blocks.schema.{Into, As, SchemaError}
259
+
260
+ case class P2D(x: Int, y: Int)
261
+ case class Coord(x: Int, y: Int)
262
+
263
+ def migrate[A, B](data: A)(implicit into: Into[A, B]): Either[SchemaError, B] =
264
+ into.into(data)
265
+
266
+ implicit val pointAs: As[P2D, Coord] = As.derived[P2D, Coord]
267
+ ```
268
+
269
+ Passing `pointAs` where the function expects `Into[P2D, Coord]` works because `As` is a subtype of `Into`:
270
+
271
+ ```scala
272
+ migrate(P2D(1, 2))
273
+ // res13: Either[SchemaError, Coord] = Right(Coord(x = 1, y = 2))
274
+ ```
275
+
276
+ ## `As.reverseInto` Implicit
277
+
278
+ `AsLowPriorityImplicits` provides `As.reverseInto`, an implicit that materialises an `Into[B, A]` from any `As[A, B]` in scope. This lets libraries that only require `Into` automatically benefit from `As` instances without any extra wiring:
279
+
280
+ ```scala
281
+ trait AsLowPriorityImplicits {
282
+ implicit def reverseInto[A, B](implicit as: As[A, B]): Into[B, A]
283
+ }
284
+ ```
285
+
286
+ With an `As[String, Int]` in scope, `As.reverseInto` synthesises `Into[Int, String]` automatically:
287
+
288
+ ```scala
289
+ import zio.blocks.schema.{As, Into, SchemaError}
290
+
291
+ implicit val stringIntAs: As[String, Int] = new As[String, Int] {
292
+ def into(s: String): Either[SchemaError, Int] =
293
+ try Right(s.toInt)
294
+ catch { case _: NumberFormatException => Left(SchemaError.validationFailed("not an int")) }
295
+ def from(n: Int): Either[SchemaError, String] = Right(n.toString)
296
+ }
297
+ ```
298
+
299
+ We import `As.reverseInto` and use it to obtain the reverse `Into[Int, String]`:
300
+
301
+ ```scala
302
+ import As.reverseInto
303
+
304
+ val intToStr: Into[Int, String] = reverseInto[String, Int]
305
+ // intToStr: Into[Int, String] = zio.blocks.schema.AsLowPriorityImplicits$$Lambda$16607/0x00007f8eb27d4250@40672bcc
306
+ intToStr.into(42)
307
+ // res14: Either[SchemaError, String] = Right("42")
308
+ ```
309
+
310
+ ## Derivation Rules
311
+
312
+ `As.derived` applies the same rules as `Into.derived` in both directions and adds bidirectional compatibility checks on top. The derivation supports the same type categories as `Into`.
313
+
314
+ ### Products (Case Classes and Tuples)
315
+
316
+ For two case classes `A` and `B`, the macro checks:
317
+ - fields with matching names must be convertible in **both** directions
318
+ - fields present in one type but absent from the other must be `Option` (defaults are not allowed — see [Restrictions](#restrictions))
319
+
320
+ We derive `As` for two structurally compatible case classes:
321
+
322
+ ```scala
323
+ import zio.blocks.schema.As
324
+
325
+ case class UserV1(name: String, age: Int)
326
+ case class UserV2(name: String, age: Long)
327
+
328
+ val userAs: As[UserV1, UserV2] = As.derived[UserV1, UserV2]
329
+ ```
330
+
331
+ Tuples are matched positionally, so field name checks are skipped:
332
+
333
+ ```scala
334
+ import zio.blocks.schema.As
335
+
336
+ val tupleAs: As[(Int, String), (Long, String)] = As.derived[(Int, String), (Long, String)]
337
+ ```
338
+
339
+ ### Coproducts (Sealed Traits and Enums)
340
+
341
+ `As.derived` handles sealed traits and Scala 3 enums the same way `Into.derived` does — each subtype is matched by name and derived recursively:
342
+
343
+ ```scala
344
+ import zio.blocks.schema._
345
+
346
+ sealed trait ShapeV1
347
+ object ShapeV1 {
348
+ case class Circle(radius: Int) extends ShapeV1
349
+ case class Rect(w: Int, h: Int) extends ShapeV1
350
+ }
351
+
352
+ sealed trait ShapeV2
353
+ object ShapeV2 {
354
+ case class Circle(radius: Long) extends ShapeV2
355
+ case class Rect(w: Long, h: Long) extends ShapeV2
356
+ }
357
+
358
+ val shapeAs: As[ShapeV1, ShapeV2] = As.derived[ShapeV1, ShapeV2]
359
+ ```
360
+
361
+ ### Numeric Coercions
362
+
363
+ All numeric primitive types (`Byte`, `Short`, `Int`, `Long`, `Float`, `Double`) are bidirectionally coercible. Widening always succeeds; narrowing validates at runtime and returns a `Left` on overflow:
364
+
365
+ ```scala
366
+ import zio.blocks.schema.As
367
+
368
+ case class IntModel(value: Int)
369
+ case class LongModel(value: Long)
370
+
371
+ val numericAs: As[IntModel, LongModel] = As.derived[IntModel, LongModel]
372
+ ```
373
+
374
+ A value within `Int` range round-trips without loss; one outside it fails on the way back:
375
+
376
+ ```scala
377
+ numericAs.into(IntModel(1000)).flatMap(numericAs.from)
378
+ // res15: Either[SchemaError, IntModel] = Right(IntModel(1000))
379
+ numericAs.from(LongModel(Long.MaxValue))
380
+ // res16: Either[SchemaError, IntModel] = Left(
381
+ // SchemaError(
382
+ // List(
383
+ // ConversionFailed(
384
+ // source = DynamicOptic(IndexedSeq()),
385
+ // details = "converting field LongModel.value to IntModel.value failed",
386
+ // cause = Some(
387
+ // SchemaError(
388
+ // List(
389
+ // ConversionFailed(
390
+ // source = DynamicOptic(ArraySeq()),
391
+ // details = "Value 9223372036854775807 is out of range for Int [-2147483648, 2147483647]",
392
+ // cause = None
393
+ // )
394
+ // )
395
+ // )
396
+ // )
397
+ // )
398
+ // )
399
+ // )
400
+ // )
401
+ ```
402
+
403
+ ## Restrictions
404
+
405
+ `As` enforces constraints that `Into` does not. Because `As.derived` must produce valid conversions in both directions, it rejects configurations that would silently lose data during a round-trip.
406
+
407
+ **Default values on asymmetric fields are rejected.** A field with a default that has no counterpart in the other type cannot be round-tripped: when converting back, the field is missing and there is no way to distinguish a real default from a missing value:
408
+
409
+ ```scala
410
+ import zio.blocks.schema._
411
+
412
+ case class WithDefault(name: String, age: Int = 25)
413
+ case class NoDefault(name: String)
414
+
415
+ // Does NOT compile — age has a default but is absent from NoDefault:
416
+ // As.derived[WithDefault, NoDefault]
417
+ ```
418
+
419
+ Default values are allowed when the field exists in **both** types, because the value is never discarded during the round-trip:
420
+
421
+ ```scala
422
+ import zio.blocks.schema.As
423
+
424
+ case class PersonA(name: String, age: Int = 25)
425
+ case class PersonB(name: String, age: Int)
426
+
427
+ As.derived[PersonA, PersonB] // compiles — age is present in both types
428
+ ```
429
+
430
+ **`Option` fields on one side are allowed.** An `Option` field absent from the other type round-trips cleanly: `Some(v)` becomes `None` after a round-trip, which is the only safe behaviour for a missing field:
431
+
432
+ ```scala
433
+ import zio.blocks.schema.As
434
+
435
+ case class TypeA(name: String, nickname: Option[String])
436
+ case class TypeB(name: String)
437
+
438
+ As.derived[TypeA, TypeB] // compiles
439
+ ```
440
+
441
+ **Numeric coercions must be invertible in both directions.** Widening `Int → Long` is automatically paired with narrowing `Long → Int`. The narrowing validates at runtime, so the round-trip is safe even though it can fail:
442
+
443
+ ```scala
444
+ import zio.blocks.schema.As
445
+
446
+ case class IntVersion(value: Int)
447
+ case class LongVersion(value: Long)
448
+
449
+ As.derived[IntVersion, LongVersion] // compiles — widening + narrowing form a valid pair
450
+ ```
451
+
452
+ **Fields present in one type but absent from the other must be `Option`.** A non-optional field that exists only on one side cannot be populated in the reverse direction:
453
+
454
+ ```scala
455
+ import zio.blocks.schema.As
456
+
457
+ case class Short_(name: String)
458
+ case class Long_(name: String, extra: String)
459
+
460
+ // Does NOT compile — extra is not Optional and does not exist in Short_:
461
+ // As.derived[Short_, Long_]
462
+
463
+ case class Long2_(name: String, extra: Option[String])
464
+
465
+ As.derived[Short_, Long2_] // compiles — extra is Optional
466
+ ```
467
+
468
+ ## DynamicValue Conversions
469
+
470
+ Like `Into`, `As` supports bidirectional conversions with `DynamicValue`, allowing you to define a single schema and use it for both type-safe operations and polyglot data handling.
471
+
472
+ ### Bidirectional DynamicValue Support with JSON Round-Trip
473
+
474
+ You can derive `As[A, DynamicValue]` for any type with a `Schema[A]` and achieve full polyglot round-trips:
475
+
476
+ ```scala
477
+ import zio.blocks.schema.*
478
+
479
+ case class Config(host: String, port: Int)
480
+
481
+ object Config {
482
+ implicit val schema: Schema[Config] = Schema.derived[Config]
483
+ val asDynamic: As[Config, DynamicValue] = As.derived[Config, DynamicValue]
484
+ }
485
+ ```
486
+
487
+ ```scala
488
+ // Forward: Config → DynamicValue → JSON
489
+ Config.asDynamic.into(Config("localhost", 8080)).map(_.toJsonString)
490
+ // res21: Either[SchemaError, String] = Right(
491
+ // "{\"host\":\"localhost\",\"port\":8080}"
492
+ // )
493
+ ```
494
+
495
+ Now in the reverse direction, deserialize JSON back to Config:
496
+
497
+ ```scala
498
+ import zio.blocks.schema.*
499
+
500
+ case class Config(host: String, port: Int)
501
+
502
+ object Config {
503
+ implicit val schema: Schema[Config] = Schema.derived[Config]
504
+ val asDynamic: As[Config, DynamicValue] = As.derived[Config, DynamicValue]
505
+
506
+ // JSON string to parse
507
+ val jsonString = """{"host":"example.com","port":9000}"""
508
+ }
509
+ ```
510
+
511
+ ```scala
512
+ // Reverse: JSON → DynamicValue → Config
513
+ for {
514
+ dv <- Config.jsonString.fromJson[DynamicValue]
515
+ config <- Config.asDynamic.from(dv)
516
+ } yield config
517
+ // res22: Either[SchemaError, Config] = Right(
518
+ // Config(host = "example.com", port = 9000)
519
+ // )
520
+ ```
521
+
522
+ The call to `jsonString.fromJson[DynamicValue]` parses the JSON string into a `DynamicValue`, and `asDynamic.from` converts it back to the strongly-typed `Config`. (Equivalently, you could use `Schema[DynamicValue].getInstance(JsonFormat).decode(jsonString)` for the same decoding step.) This demonstrates the full cycle: **JSON → DynamicValue → Type**, ensuring perfect round-trip fidelity.
523
+
524
+ ### Use Cases
525
+
526
+ `As` is ideal when data must flow in both directions within the same system, with guarantees that neither direction silently loses or corrupts data.
527
+
528
+ #### Polyglot configuration systems
529
+
530
+ Configuration is often stored externally (Consul, etcd, a JSON file) and must be read, modified in-place, and written back. A naive approach requires two separate conversions — `Into[DynamicValue, DatabaseConfig]` to read and `Into[DatabaseConfig, DynamicValue]` to write — with no guarantee they align. `As` solves this by providing a single bidirectional instance that the macro verifies will round-trip faithfully.
531
+
532
+ Consider a service that:
533
+ 1. Reads config from an external store (JSON → `DynamicValue` → typed `DatabaseConfig`)
534
+ 2. Applies business logic to the typed config (validate, scale, migrate)
535
+ 3. Writes the updated config back to the store (typed `DatabaseConfig` → `DynamicValue` → JSON)
536
+
537
+ Without `As`, step 3 might serialize data differently than step 1 read it, causing silent corruption or misalignment. With `As`, the macro guarantees that `config → DynamicValue → config'` preserves the structure.
538
+
539
+ ```scala
540
+ import zio.blocks.schema.*
541
+
542
+ case class DatabaseConfig(host: String, port: Int, timeout: Long)
543
+
544
+ object DatabaseConfig {
545
+ implicit val schema: Schema[DatabaseConfig] = Schema.derived[DatabaseConfig]
546
+ val asDynamic: As[DatabaseConfig, DynamicValue] = As.derived[DatabaseConfig, DynamicValue]
547
+ }
548
+ ```
549
+
550
+ ```scala
551
+ // Simulate JSON arriving from the config store (e.g. Consul, etcd, a JSON file)
552
+ val storedJson = """{"host":"db.prod.example.com","port":5432,"timeout":30000}"""
553
+ // storedJson: String = "{\"host\":\"db.prod.example.com\",\"port\":5432,\"timeout\":30000}"
554
+
555
+ val result = for {
556
+ stored <- storedJson.fromJson[DynamicValue] // Step 1: Read from store
557
+ config <- DatabaseConfig.asDynamic.from(stored) // Step 2a: Hydrate into typed config
558
+ updated = config.copy(timeout = 60000) // Step 2b: Apply business logic
559
+ written <- DatabaseConfig.asDynamic.into(updated) // Step 3: Serialize back to store (guaranteed round-trip)
560
+ } yield written.toJsonString
561
+ // result: Either[SchemaError, String] = Right(
562
+ // "{\"host\":\"db.prod.example.com\",\"port\":5432,\"timeout\":60000}"
563
+ // )
564
+
565
+ result
566
+ // res23: Either[SchemaError, String] = Right(
567
+ // "{\"host\":\"db.prod.example.com\",\"port\":5432,\"timeout\":60000}"
568
+ // )
569
+ ```
570
+
571
+ ## Scala 2 vs Scala 3 Differences
572
+
573
+ | Feature | Scala 2 | Scala 3 |
574
+ |---------|---------|---------|
575
+ | Derivation syntax | `As.derived[A, B]` | `As.derived[A, B]` |
576
+ | Enum support | Sealed traits only | Scala 3 enums + sealed traits |
577
+ | Opaque types | N/A | ✅ Supported |
578
+ | Structural types | JVM only (reflection) | JVM only (reflection) |
579
+ | ZIO Prelude newtypes | ✅ `assert { between(...) }` | ✅ `override def assertion` |
580
+ | Error messages | Detailed macro errors | Detailed macro errors |
581
+ | DynamicValue ambiguity detection | ✅ Two-pass implicit resolution | ✅ Built-in ambiguity reporting |
582
+
583
+ ## Integration
584
+
585
+ `As[A, B]` is defined in `zio.blocks.schema` alongside `Into[A, B]`. Because `As` is a subtype of `Into`, the two type classes compose naturally: you can derive an outer `As` from inner `As` instances, or mix `As` and custom `Into` instances when some fields need one-way or custom logic.
586
+
587
+ For a full reference on one-way conversions and the derivation rules that `As` builds on, see [Into](./into.md).
@@ -0,0 +1,50 @@
1
+ ---
2
+ id: index
3
+ title: "Schema Evolution"
4
+ ---
5
+
6
+ Schema evolution is the process of changing data structures over time while keeping existing data readable and systems interoperable. ZIO Blocks provides two type classes for this: `Into` for one-way conversions and `As` for bidirectional round-trip conversions.
7
+
8
+ ```
9
+ Into[A, B] As[A, B]
10
+ ───────────────────── ──────────────────────────
11
+ A ──── into(a) ──── B A ──── into(a) ────► B
12
+ A ◄─── from(b) ──── B
13
+ One-way, asymmetric Bidirectional, round-trip
14
+ Allows defaults, drops Requires fields to match
15
+ extra fields freely or be Option; no defaults
16
+ on asymmetric fields
17
+ ```
18
+
19
+ ## `Into[A, B]` — One-Way Conversion
20
+
21
+ [`Into[A, B]`](./into.md) converts a value of type `A` to `Either[SchemaError, B]`. It is the right choice whenever the migration is asymmetric — for example, when adding a field with a default value, removing a field, or transforming data in a way that cannot be reversed.
22
+
23
+ Typical use cases:
24
+
25
+ - Migrating records from an old schema version to a new one
26
+ - Translating an external DTO into a validated domain model
27
+ - Converting API responses to internal representations
28
+
29
+ ## `As[A, B]` — Bidirectional Round-Trip
30
+
31
+ [`As[A, B]`](./as.md) extends `Into[A, B]` with a `from(b: B): Either[SchemaError, A]` reverse direction. It guarantees that `A → B → A` restores the original value (within the constraints of numeric precision and optional fields). Use `As` when both sides of the conversion must remain in sync.
32
+
33
+ Typical use cases:
34
+
35
+ - Synchronising a local model with a remote representation
36
+ - Persisting to a data format that must be readable back into the same type
37
+ - Bridging two live systems that both produce and consume the same data
38
+
39
+ ## Choosing Between `Into` and `As`
40
+
41
+ | | `Into[A, B]` | `As[A, B]` |
42
+ |---|---|---|
43
+ | Reverse conversion | ✗ | ✅ `from(b)` |
44
+ | Default values on extra fields | ✅ allowed | ✗ not allowed |
45
+ | Optional asymmetric fields | ✅ | ✅ (`Option` only) |
46
+ | Numeric coercion | ✅ (widening + narrowing) | ✅ (must be invertible) |
47
+ | Use for one-way migrations | ✅ | possible but overly strict |
48
+ | Use for bidirectional sync | manual | ✅ |
49
+
50
+ When in doubt, start with `Into`. Upgrade to `As` only when you need the reverse direction and can satisfy its stricter derivation requirements.