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