@zio.dev/zio-blocks 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,540 @@
1
+ ---
2
+ id: schema-evolution
3
+ title: "Schema Evolution"
4
+ sidebar_label: "Schema Evolution"
5
+ ---
6
+
7
+ Schema evolution is a common challenge in distributed systems where data structures change over time. ZIO Blocks provides two type classes—`Into` and `As`—that enable type-safe, compile-time verified transformations between different versions of your data types.
8
+
9
+ ## Overview
10
+
11
+ When your application evolves, you often need to:
12
+
13
+ - Add new fields to existing types
14
+ - Remove deprecated fields
15
+ - Rename fields
16
+ - Change field types (e.g., `Int` → `Long`)
17
+ - Convert between different representations of the same concept
18
+
19
+ ZIO Blocks handles these transformations with:
20
+
21
+ | Type Class | Direction | Use Case |
22
+ |------------|-----------|----------|
23
+ | `Into[A, B]` | One-way (A → B) | Migrations, API responses, data import |
24
+ | `As[A, B]` | Bidirectional (A ↔ B) | Round-trip serialization, data sync |
25
+
26
+ ## Into[A, B] - One-Way Conversion
27
+
28
+ `Into[A, B]` represents a one-way conversion from type `A` to type `B` with validation:
29
+
30
+ ```scala
31
+ trait Into[-A, +B] {
32
+ def into(a: A): Either[SchemaError, B]
33
+ }
34
+ ```
35
+
36
+ ### Basic Usage
37
+
38
+ ```scala
39
+ import zio.blocks.schema.Into
40
+
41
+ // Version 1 of our API
42
+ case class PersonV1(name: String, age: Int)
43
+
44
+ // Version 2 adds email and changes age to Long
45
+ case class PersonV2(name: String, age: Long, email: Option[String])
46
+
47
+ // Derive the conversion automatically
48
+ val migrate: Into[PersonV1, PersonV2] = Into.derived[PersonV1, PersonV2]
49
+
50
+ // Use it
51
+ val v1 = PersonV1("Alice", 30)
52
+ val v2 = migrate.into(v1)
53
+ // Right(PersonV2("Alice", 30L, None))
54
+ ```
55
+
56
+ ### Field Matching Rules
57
+
58
+ The macro matches fields using the following priority:
59
+
60
+ 1. **Exact match**: Same name + same type
61
+ 2. **Name match with coercion**: Same name + convertible type (e.g., `Int` → `Long`)
62
+ 3. **Unique type match**: Type appears only once in both source and target
63
+ 4. **Position + type match**: Positional correspondence with matching type
64
+
65
+ ### Handling Missing Fields
66
+
67
+ When the target has fields not present in the source:
68
+
69
+ ```scala
70
+ case class Source(name: String)
71
+ case class Target(name: String, age: Int = 25, nickname: Option[String])
72
+
73
+ val convert = Into.derived[Source, Target]
74
+ convert.into(Source("Bob"))
75
+ // Right(Target("Bob", 25, None))
76
+ // ↑ ↑
77
+ // default Option defaults to None
78
+ ```
79
+
80
+ - **Default values**: Used when target field has a default
81
+ - **Option types**: Default to `None` when not present in source
82
+
83
+ ### Numeric Conversions
84
+
85
+ Built-in support for numeric widening and narrowing:
86
+
87
+ ```scala
88
+ // Widening (lossless) - always succeeds
89
+ Into[Byte, Short] // Byte → Short
90
+ Into[Int, Long] // Int → Long
91
+ Into[Float, Double] // Float → Double
92
+
93
+ // Narrowing (with validation) - may fail at runtime
94
+ Into[Long, Int] // Fails if value > Int.MaxValue or < Int.MinValue
95
+ Into[Double, Float] // Fails if value out of Float range
96
+ ```
97
+
98
+ Example with validation failure:
99
+
100
+ ```scala
101
+ case class BigNumbers(value: Long)
102
+ case class SmallNumbers(value: Int)
103
+
104
+ val narrow = Into.derived[BigNumbers, SmallNumbers]
105
+
106
+ narrow.into(BigNumbers(42L))
107
+ // Right(SmallNumbers(42))
108
+
109
+ narrow.into(BigNumbers(Long.MaxValue))
110
+ // Left(SchemaError: "Value 9223372036854775807 is out of range for Int")
111
+ ```
112
+
113
+ ### Collection Conversions
114
+
115
+ Automatic conversion between collection types:
116
+
117
+ ```scala
118
+ case class ListData(items: List[Int])
119
+ case class VectorData(items: Vector[Long])
120
+
121
+ Into.derived[ListData, VectorData]
122
+ // Converts List → Vector AND Int → Long
123
+ ```
124
+
125
+ Supported conversions:
126
+ - `List`, `Vector`, `Set`, `Seq` (interchangeable)
127
+ - `Array` ↔ `Iterable`
128
+ - `Map[K1, V1]` → `Map[K2, V2]`
129
+ - `Option[A]` → `Option[B]`
130
+ - `Either[L1, R1]` → `Either[L2, R2]`
131
+
132
+ :::note
133
+ Converting to `Set` may remove duplicates. Converting from `Set` does not preserve any particular ordering.
134
+ :::
135
+
136
+ ### Sealed Trait / Enum Conversions
137
+
138
+ Convert between coproduct types (sealed traits, enums):
139
+
140
+ ```scala
141
+ // Scala 2
142
+ sealed trait StatusV1
143
+ object StatusV1 {
144
+ case object Active extends StatusV1
145
+ case object Inactive extends StatusV1
146
+ }
147
+
148
+ sealed trait StatusV2
149
+ object StatusV2 {
150
+ case object Active extends StatusV2
151
+ case object Inactive extends StatusV2
152
+ case object Pending extends StatusV2 // New case added
153
+ }
154
+
155
+ // Scala 3
156
+ enum StatusV1 { case Active, Inactive }
157
+ enum StatusV2 { case Active, Inactive, Pending }
158
+
159
+ // Works - all V1 cases exist in V2
160
+ Into.derived[StatusV1, StatusV2]
161
+ ```
162
+
163
+ Cases are matched by:
164
+ 1. **Name**: Case names must match
165
+ 2. **Signature**: For case classes, field types must be convertible
166
+
167
+ ### Nested Type Conversions
168
+
169
+ For nested types, provide implicit `Into` instances:
170
+
171
+ ```scala
172
+ case class AddressV1(street: String, zip: Int)
173
+ case class AddressV2(street: String, zip: Long)
174
+
175
+ case class PersonV1(name: String, address: AddressV1)
176
+ case class PersonV2(name: String, address: AddressV2)
177
+
178
+ // The macro automatically uses Into[AddressV1, AddressV2] for the nested field
179
+ val convert = Into.derived[PersonV1, PersonV2]
180
+ ```
181
+
182
+ ### Error Accumulation
183
+
184
+ When multiple fields fail validation, all errors are accumulated:
185
+
186
+ ```scala
187
+ case class Source(a: Long, b: Long, c: Long)
188
+ case class Target(a: Int, b: Int, c: Int)
189
+
190
+ val convert = Into.derived[Source, Target]
191
+ convert.into(Source(Long.MaxValue, Long.MinValue, 42L))
192
+ // Left(SchemaError containing errors for BOTH 'a' and 'b')
193
+ ```
194
+
195
+ ## As[A, B] - Bidirectional Conversion
196
+
197
+ `As[A, B]` extends `Into[A, B]` with a reverse conversion:
198
+
199
+ ```scala
200
+ trait As[A, B] extends Into[A, B] {
201
+ def from(input: B): Either[SchemaError, A]
202
+ def reverse: As[B, A]
203
+ }
204
+ ```
205
+
206
+ ### Basic Usage
207
+
208
+ ```scala
209
+ import zio.blocks.schema.As
210
+
211
+ case class Point2D(x: Int, y: Int)
212
+ case class Coordinate(x: Int, y: Int)
213
+
214
+ val convert: As[Point2D, Coordinate] = As.derived[Point2D, Coordinate]
215
+
216
+ // Both directions work
217
+ convert.into(Point2D(1, 2)) // Right(Coordinate(1, 2))
218
+ convert.from(Coordinate(3, 4)) // Right(Point2D(3, 4))
219
+
220
+ // Swap directions
221
+ val reversed: As[Coordinate, Point2D] = convert.reverse
222
+ ```
223
+
224
+ ### Restrictions for As
225
+
226
+ `As` has stricter requirements than `Into` to guarantee round-trip safety:
227
+
228
+ #### ❌ No Default Values on Non-Matching Fields
229
+
230
+ Default values break round-trip when a field with a default exists in one type but not in the other:
231
+
232
+ ```scala
233
+ case class WithDefault(name: String, age: Int = 25)
234
+ case class NoDefault(name: String)
235
+
236
+ // This will NOT compile (age has default but doesn't exist in NoDefault):
237
+ As.derived[WithDefault, NoDefault]
238
+ // Error: "Cannot derive As[...]: Default values break round-trip guarantee"
239
+
240
+ // Use Into instead for one-way conversion:
241
+ Into.derived[NoDefault, WithDefault] // ✓ Works
242
+ ```
243
+
244
+ However, default values ARE allowed when the field exists in both types:
245
+
246
+ ```scala
247
+ case class PersonA(name: String, age: Int = 25)
248
+ case class PersonB(name: String, age: Int)
249
+
250
+ // This WILL compile (age exists in both types):
251
+ As.derived[PersonA, PersonB] // ✓ Works
252
+ ```
253
+
254
+ #### ✅ Option Fields Are Allowed
255
+
256
+ Option fields work because `None` round-trips correctly:
257
+
258
+ ```scala
259
+ case class TypeA(name: String, nickname: Option[String])
260
+ case class TypeB(name: String)
261
+
262
+ As.derived[TypeA, TypeB] // ✓ Works
263
+ ```
264
+
265
+ #### ✅ Numeric Coercions Must Be Invertible
266
+
267
+ Numeric types can be coerced if the conversion works in both directions:
268
+
269
+ ```scala
270
+ case class IntVersion(value: Int)
271
+ case class LongVersion(value: Long)
272
+
273
+ As.derived[IntVersion, LongVersion]
274
+ // ✓ Works: Int → Long (widening) and Long → Int (narrowing with validation)
275
+ ```
276
+
277
+ ### Using As Where Into Is Expected
278
+
279
+ Since `As[A, B]` extends `Into[A, B]`, you can use it anywhere an `Into` is required:
280
+
281
+ ```scala
282
+ def migrate[A, B](data: A)(implicit into: Into[A, B]): Either[SchemaError, B] =
283
+ into.into(data)
284
+
285
+ implicit val as: As[Point2D, Coordinate] = As.derived
286
+
287
+ migrate(Point2D(1, 2)) // Uses As as an Into
288
+ ```
289
+
290
+ ## ZIO Prelude Newtype Support
291
+
292
+ Both `Into` and `As` automatically detect and validate ZIO Prelude newtypes:
293
+
294
+ ```scala
295
+ import zio.prelude._
296
+
297
+ object Age extends Subtype[Int] {
298
+ override def assertion = assert(between(0, 150))
299
+ }
300
+ type Age = Age.Type
301
+
302
+ case class PersonRaw(name: String, age: Int)
303
+ case class PersonValidated(name: String, age: Age)
304
+
305
+ val validate = Into.derived[PersonRaw, PersonValidated]
306
+
307
+ validate.into(PersonRaw("Alice", 30))
308
+ // Right(PersonValidated("Alice", Age(30)))
309
+
310
+ validate.into(PersonRaw("Bob", -5))
311
+ // Left(SchemaError: "Validation failed for field 'age': ...")
312
+ ```
313
+
314
+ The macro automatically:
315
+ 1. Detects that `Age` is a ZIO Prelude newtype
316
+ 2. Calls `Age.make(value)` for validation
317
+ 3. Converts `Validation` result to `Either[SchemaError, _]`
318
+
319
+ ## Scala 3 Opaque Type Support
320
+
321
+ In Scala 3, opaque types with companion validation are supported:
322
+
323
+ ```scala
324
+ opaque type Email = String
325
+ object Email {
326
+ def apply(value: String): Either[String, Email] =
327
+ if (value.contains("@")) Right(value)
328
+ else Left(s"Invalid email: $value")
329
+
330
+ def unsafe(value: String): Email = value
331
+ }
332
+
333
+ case class UserRaw(name: String, email: String)
334
+ case class UserValidated(name: String, email: Email)
335
+
336
+ Into.derived[UserRaw, UserValidated]
337
+ // Automatically uses Email.apply for validation
338
+ ```
339
+
340
+ The macro looks for:
341
+ 1. `apply(value: Underlying): Either[_, OpaqueType]` - validation method
342
+ 2. `unsafe(value: Underlying): OpaqueType` - fallback without validation
343
+
344
+ ## Structural Type Support
345
+
346
+ ZIO Blocks supports conversions involving structural types on JVM only, as they require reflection.
347
+
348
+ ### Platform Compatibility Matrix
349
+
350
+ | Conversion | JVM | JS | Notes |
351
+ |------------|-----|-----|-------|
352
+ | Product → Structural | ✅ | ❌ | JVM only (reflection) |
353
+ | Structural → Product | ✅ | ❌ | JVM only (reflection) |
354
+
355
+ **Key insight**: Structural types require runtime reflection to access their members, which is only available on JVM. On JS, structural type conversions will fail at compile time with a helpful error message.
356
+
357
+ ### Structural Types (JVM Only)
358
+
359
+ Structural types are types defined by their members rather than their name:
360
+
361
+ ```scala
362
+ // JVM ONLY - will fail at compile time on JS
363
+ case class Person(name: String, age: Int)
364
+
365
+ // Structural type to case class
366
+ val into = Into.derived[{ def name: String; def age: Int }, Person]
367
+
368
+ // Case class to structural type
369
+ val toStructural = Into.derived[Person, { def name: String; def age: Int }]
370
+ ```
371
+
372
+ **Compile-time error on non-JVM platforms:**
373
+ ```
374
+ Cannot derive Into[..., Person]: Structural type conversions are not supported on JS.
375
+
376
+ Structural types require reflection APIs (getClass.getMethod) which are only available on JVM.
377
+
378
+ Consider:
379
+ - Using a case class instead of a structural type
380
+ - Using a tuple instead of a structural type
381
+ - Only using structural type conversions in JVM-only code
382
+ ```
383
+
384
+ ### Working with Structural Types
385
+
386
+ ```scala
387
+ import scala.language.reflectiveCalls
388
+
389
+ // Create a structural type instance
390
+ def makePerson(n: String, a: Int): { def name: String; def age: Int } = new {
391
+ def name: String = n
392
+ def age: Int = a
393
+ }
394
+
395
+ case class Person(name: String, age: Int)
396
+
397
+ // Convert structural → case class
398
+ val into = Into.derived[{ def name: String; def age: Int }, Person]
399
+ val result = into.into(makePerson("Alice", 30))
400
+ // Right(Person("Alice", 30))
401
+ ```
402
+
403
+ ## Scala 2 vs Scala 3 Differences
404
+
405
+ | Feature | Scala 2 | Scala 3 |
406
+ |---------|---------|---------|
407
+ | Derivation syntax | `Into.derived[A, B]` | `Into.derived[A, B]` |
408
+ | Enum support | Sealed traits only | Scala 3 enums + sealed traits |
409
+ | Opaque types | N/A | ✅ Supported |
410
+ | Structural types | JVM only (reflection) | JVM only (reflection) |
411
+ | ZIO Prelude newtypes | ✅ `assert { ... }` syntax | ✅ `override def assertion` syntax |
412
+ | Error messages | Detailed macro errors | Detailed macro errors |
413
+
414
+ ### ZIO Prelude Newtype Syntax
415
+
416
+ **Scala 2:**
417
+ ```scala
418
+ object Age extends Subtype[Int] {
419
+ override def assertion = assert {
420
+ between(0, 150)
421
+ }
422
+ }
423
+ ```
424
+
425
+ **Scala 3:**
426
+ ```scala
427
+ object Age extends Subtype[Int] {
428
+ override def assertion: Assertion[Int] =
429
+ zio.prelude.Assertion.between(0, 150)
430
+ }
431
+ ```
432
+
433
+ ## Best Practices
434
+
435
+ ### 1. Prefer As When Round-Trip Is Required
436
+
437
+ ```scala
438
+ // For data sync, use As
439
+ val sync: As[LocalModel, RemoteModel] = As.derived
440
+
441
+ // For one-way migrations, use Into
442
+ val migrate: Into[OldFormat, NewFormat] = Into.derived
443
+ ```
444
+
445
+ ### 2. Use Option for Truly Optional Fields
446
+
447
+ ```scala
448
+ // Good: Optional field with Option
449
+ case class V2(name: String, email: Option[String])
450
+
451
+ // Avoid: Default values break As derivation
452
+ case class V2(name: String, email: String = "")
453
+ ```
454
+
455
+ ### 3. Provide Explicit Instances for Complex Nested Types
456
+
457
+ ```scala
458
+ // When nested types need custom logic
459
+ implicit val addressConvert: Into[AddressV1, AddressV2] =
460
+ Into.derived[AddressV1, AddressV2]
461
+
462
+ // Now this works automatically
463
+ val personConvert = Into.derived[PersonV1, PersonV2]
464
+ ```
465
+
466
+ ### 4. Structural Types Are JVM-Only
467
+
468
+ ```scala
469
+ // JVM-only: Structural types require reflection
470
+ type PersonLike = { def name: String }
471
+
472
+ // For cross-platform code, use case classes instead
473
+ case class PersonLike(name: String)
474
+ ```
475
+
476
+ ## Complete Example
477
+
478
+ Here's a complete schema evolution example:
479
+
480
+ ```scala
481
+ import zio.blocks.schema._
482
+
483
+ // API v1
484
+ object V1 {
485
+ case class Address(street: String, city: String)
486
+ case class Person(name: String, age: Int, address: Address)
487
+ }
488
+
489
+ // API v2 - adds fields, changes types
490
+ object V2 {
491
+ case class Address(street: String, city: String, country: String = "US")
492
+ case class Person(
493
+ name: String,
494
+ age: Long, // Changed from Int
495
+ address: Address,
496
+ email: Option[String] // New field
497
+ )
498
+ }
499
+
500
+ // Define conversions
501
+ object Migrations {
502
+ // Address: one-way (v2 has default for country)
503
+ implicit val addressMigrate: Into[V1.Address, V2.Address] =
504
+ Into.derived[V1.Address, V2.Address]
505
+
506
+ // Person: one-way (v2 has new optional field)
507
+ implicit val personMigrate: Into[V1.Person, V2.Person] =
508
+ Into.derived[V1.Person, V2.Person]
509
+ }
510
+
511
+ // Usage
512
+ import Migrations._
513
+
514
+ val oldPerson = V1.Person("Alice", 30, V1.Address("123 Main St", "NYC"))
515
+ val newPerson = personMigrate.into(oldPerson)
516
+ // Right(V2.Person("Alice", 30L, V2.Address("123 Main St", "NYC", "US"), None))
517
+ ```
518
+
519
+ ## Error Handling
520
+
521
+ All conversions return `Either[SchemaError, B]` for explicit error handling:
522
+
523
+ ```scala
524
+ val result = migrate.into(oldData)
525
+
526
+ result match {
527
+ case Right(newData) =>
528
+ // Success - use newData
529
+
530
+ case Left(error) =>
531
+ // Handle validation/conversion failure
532
+ println(s"Migration failed: ${error.message}")
533
+ }
534
+ ```
535
+
536
+ `SchemaError` provides:
537
+ - Detailed error messages
538
+ - Field path information
539
+ - Error accumulation (multiple errors combined)
540
+