@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.
@@ -70,13 +70,13 @@ val result: Either[OpticCheck, Seq[Boolean]] = combined.eval(alice)
70
70
  ## Installation
71
71
 
72
72
  ```scala
73
- libraryDependencies += "dev.zio" %% "zio-blocks-schema" % "0.0.26"
73
+ libraryDependencies += "dev.zio" %% "zio-blocks-schema" % "0.0.28"
74
74
  ```
75
75
 
76
76
  For cross-platform (Scala.js):
77
77
 
78
78
  ```scala
79
- libraryDependencies += "dev.zio" %%% "zio-blocks-schema" % "0.0.26"
79
+ libraryDependencies += "dev.zio" %%% "zio-blocks-schema" % "0.0.28"
80
80
  ```
81
81
 
82
82
  Supported Scala versions: 2.13.x and 3.x.
@@ -618,3 +618,32 @@ object UserId {
618
618
  .transform(UserId(_), _.value)
619
619
  }
620
620
  ```
621
+
622
+ ## Compile-Time Shape Constraints (`Allows`)
623
+
624
+ ZIO Blocks provides `Allows[A, S]` — a phantom-typed capability token that proves, at compile time, that type `A` satisfies the structural grammar `S`. This lets library authors express and enforce structural preconditions on their generic APIs without writing macros themselves.
625
+
626
+ ```scala
627
+ import zio.blocks.schema.Schema
628
+ import zio.blocks.schema.comptime.Allows
629
+ import Allows._
630
+
631
+ // Require a flat record of scalars (e.g. for CSV or RDBMS)
632
+ def writeCsv[A: Schema](rows: Seq[A])(using
633
+ Allows[A, Record[Primitive | Optional[Primitive]]]
634
+ ): Unit = ???
635
+
636
+ // Sealed traits auto-unwrap: each case must satisfy Record[...] — no Variant node needed
637
+ def publish[A: Schema](event: A)(using
638
+ Allows[A, Record[Primitive | Sequence[Primitive]]]
639
+ ): Unit = ???
640
+
641
+ // Recursive grammar (e.g. for a JSON document store)
642
+ def toJson[A: Schema](doc: A)(using
643
+ Allows[A, Record[Primitive | Self | Optional[Primitive | Self] | Sequence[Primitive | Self]]]
644
+ ): String = ???
645
+ ```
646
+
647
+ When a type does not satisfy the grammar, the user gets a precise compile-time error naming the violating field and suggesting a fix. No runtime surprises.
648
+
649
+ See the [`Allows` reference](./allows.md) for the full grammar node table, union syntax, `Self` for recursive types, newtypes, and error message examples.
@@ -0,0 +1,369 @@
1
+ ---
2
+ id: structural-types
3
+ title: "Structural Types"
4
+ ---
5
+
6
+ <!--
7
+ BLOCKING ISSUE: Scala 3.7.4 Compiler Crash
8
+ ============================================
9
+
10
+ This file uses structural type syntax ({ def field: Type }) which triggers a compiler
11
+ crash in Scala 3.7.4 during the erasure phase. Therefore, code blocks here are marked
12
+ as plain `scala` instead of `mdoc:compile-only` to allow the documentation build to pass.
13
+
14
+ WORKAROUND: Code examples are NOT compiled/type-checked until Scala 3.8.x is adopted.
15
+
16
+ TODO (When Scala 3.8.x is adopted):
17
+ 1. Change all `\`\`\`scala` blocks back to `\`\`\`scala mdoc:compile-only`
18
+ 2. Run `sbt docs/mdoc` to verify compilation succeeds
19
+ 3. Delete this comment
20
+
21
+ Reference:
22
+ - Scala 3.7.4 compiler issue: https://github.com/scala/scala3/issues/24598 (or similar)
23
+ - Scala 3.8.x migration: PR #1169 "Preparing to migration on Scala 3.8.x"
24
+ - This PR: Addresses structural-types.md CI failures
25
+ -->
26
+
27
+ Structural types enable **duck typing** with ZIO Blocks schemas. Instead of requiring a nominal type name (like `class Person`), a structural schema validates based on the **shape** of an object — the fields it provides, regardless of how it was defined.
28
+
29
+ ## Motivation
30
+
31
+ Consider a common integration scenario:
32
+
33
+ ```scala
34
+ // Your system
35
+ case class Person(name: String, age: Int)
36
+
37
+ // External system (same data, different class)
38
+ case class User(name: String, age: Int)
39
+ ```
40
+
41
+ Without structural types, converting between `Person` and `User` requires manual translation. With structural types, they both have the same structural schema:
42
+
43
+ ```scala
44
+ import scala.language.reflectiveCalls
45
+ import zio.blocks.schema.Schema
46
+
47
+ case class Person(name: String, age: Int)
48
+ case class User(name: String, age: Int)
49
+
50
+ val personSchema = Schema.derived[Person]
51
+ val personStructural = personSchema.structural
52
+ // Schema[{ def name: String; def age: Int }]
53
+
54
+ val userSchema = Schema.derived[User]
55
+ val userStructural = userSchema.structural
56
+ // Schema[{ def name: String; def age: Int }]
57
+
58
+ // Both schemas accept the same data shape
59
+ ```
60
+
61
+ ## Construction: `Schema#structural`
62
+
63
+ Use the `Schema#structural` method on any schema to get the corresponding structural schema.
64
+
65
+ **Scala 3:** Using transparent inline — the return type is inferred to the full refinement type:
66
+
67
+ ```scala
68
+ import zio.blocks.schema.Schema
69
+
70
+ case class Person(name: String, age: Int)
71
+ object Person {
72
+ implicit val schema: Schema[Person] = Schema.derived[Person]
73
+ }
74
+
75
+ val personSchema: Schema[Person] = Schema.derived[Person]
76
+ val structuralSchema: Schema[{ def name: String; def age: Int }] = personSchema.structural
77
+ ```
78
+
79
+ **Scala 2:** Implicit derivation — returns `Schema[ts.StructuralType]` (path-dependent type):
80
+
81
+ ```scala
82
+ import zio.blocks.schema.Schema
83
+
84
+ case class Person(name: String, age: Int)
85
+ object Person {
86
+ implicit val schema: Schema[Person] = Schema.derived[Person]
87
+ }
88
+
89
+ val personSchema: Schema[Person] = Schema.derived[Person]
90
+ val structuralSchema = personSchema.structural
91
+ // Type: Schema[ts.StructuralType] (structural type inferred from macro)
92
+ ```
93
+
94
+ ## Supported Conversions
95
+
96
+ The following type categories can be converted to structural schemas:
97
+
98
+ ### Product Types (Case Classes)
99
+
100
+ Both Scala 2 and 3 support structural conversion of case classes:
101
+
102
+ ```scala
103
+ import zio.blocks.schema.Schema
104
+
105
+ case class Address(street: String, city: String, zipCode: Int)
106
+ object Address {
107
+ implicit val schema: Schema[Address] = Schema.derived[Address]
108
+ }
109
+
110
+ val schema = Schema.derived[Address]
111
+ val structural = schema.structural
112
+ // Schema[{ def street: String; def city: String; def zipCode: Int }]
113
+ ```
114
+
115
+ ### Tuples
116
+
117
+ Tuples convert to structural records with field names derived from positions:
118
+
119
+ ```scala
120
+ import zio.blocks.schema.Schema
121
+
122
+ type StringIntBool = (String, Int, Boolean)
123
+ implicit val schema: Schema[StringIntBool] = Schema.derived[StringIntBool]
124
+
125
+ val tupleSchema = Schema.derived[(String, Int, Boolean)]
126
+ val structuralSchema = tupleSchema.structural
127
+ // Schema[{ def _1: String; def _2: Int; def _3: Boolean }]
128
+ ```
129
+
130
+ ### Nested Products
131
+
132
+ Nested product fields keep their nominal types; only the outer product is structuralized:
133
+
134
+ ```scala
135
+ import zio.blocks.schema.Schema
136
+
137
+ case class Address(street: String, city: String)
138
+ object Address {
139
+ implicit val schema: Schema[Address] = Schema.derived[Address]
140
+ }
141
+
142
+ case class Person(name: String, age: Int, address: Address)
143
+ object Person {
144
+ implicit val schema: Schema[Person] = Schema.derived[Person]
145
+ }
146
+
147
+ val personSchema = Schema.derived[Person]
148
+ val structuralSchema = personSchema.structural
149
+ // Schema[{
150
+ // def name: String
151
+ // def age: Int
152
+ // def address: Address
153
+ // }]
154
+ ```
155
+
156
+ ### Opaque Types (Scala 3)
157
+
158
+ Opaque type aliases are unwrapped to their underlying type:
159
+
160
+ ```scala
161
+ import zio.blocks.schema.Schema
162
+
163
+ opaque type UserId = String
164
+
165
+ case class User(id: UserId, name: String)
166
+ object User {
167
+ implicit val schema: Schema[User] = Schema.derived[User]
168
+ }
169
+
170
+ val schema = Schema.derived[User]
171
+ val structural = schema.structural
172
+ // Schema[{ def id: String; def name: String }]
173
+ // (UserId unwrapped to String)
174
+ ```
175
+
176
+ ### Sum Types / Sealed Traits (Scala 3)
177
+
178
+ Sealed traits and enums convert to union types with nested method syntax:
179
+
180
+ ```scala
181
+ import zio.blocks.schema.Schema
182
+
183
+ sealed trait Shape
184
+ object Shape {
185
+ case class Circle(radius: Double) extends Shape
186
+ case class Rectangle(width: Double, height: Double) extends Shape
187
+ implicit val schema: Schema[Shape] = Schema.derived[Shape]
188
+ }
189
+
190
+ val schema = Schema.derived[Shape]
191
+ val structural = schema.structural
192
+ // Schema[
193
+ // { def Circle: { def radius: Double } } |
194
+ // { def Rectangle: { def height: Double; def width: Double } }
195
+ // ]
196
+ // (cases sorted alphabetically)
197
+ ```
198
+
199
+ **Enum syntax** (Scala 3):
200
+
201
+ ```scala
202
+ import zio.blocks.schema.Schema
203
+
204
+ enum Color {
205
+ case Red, Green, Blue
206
+ }
207
+ object Color {
208
+ implicit val schema: Schema[Color] = Schema.derived[Color]
209
+ }
210
+
211
+ val schema = Schema.derived[Color]
212
+ val structural = schema.structural
213
+ // Schema[
214
+ // { def Blue: {} } |
215
+ // { def Green: {} } |
216
+ // { def Red: {} }
217
+ // ]
218
+ ```
219
+
220
+ Cases appear in **alphabetical order** in the union type. This alphabetical ordering (applied to fields in products and case names in unions) ensures **deterministic, normalized type identity**: two structural types with the same fields but different declaration order produce the same structural type and normalized name. This is essential for predictable schema evolution and cross-system interop.
221
+
222
+ ## Direct Structural Derivation (Scala 3)
223
+
224
+ Create a schema directly for a structural type without a nominal base:
225
+
226
+ ```scala
227
+ import zio.blocks.schema.Schema
228
+
229
+ // No case class needed — define the schema for the shape directly
230
+ val personStructural = Schema.derived[{ def name: String; def age: Int }]
231
+
232
+ // The schema is ready to use with values matching that structural shape
233
+ ```
234
+
235
+ This is only supported in **Scala 3** with the right macro machinery.
236
+
237
+ ## Round-tripping Through DynamicValue
238
+
239
+ Structural schemas enable **cross-type conversion through `DynamicValue`** — encode a value of one nominal type and decode it as a *different* nominal type with the same structural shape. This is the core benefit of structural types for system integration.
240
+
241
+ ### Motivation
242
+
243
+ In real integrations, you often receive data from an external system shaped like one type, but you need to work with it as a different type in your system. Without structural types, field-by-field translation is required. With structural types, if both types have identical shape, `DynamicValue` acts as the seamless bridge.
244
+
245
+ Common scenarios:
246
+ - **API gateways** — receive a `PersonDTO` from an external API, decode as your internal `Person` type
247
+ - **Message brokers** — consume an event shaped like `UserEvent`, convert to your domain `Account` type
248
+ - **Data pipelines** — records with identical fields but different class names from different services
249
+
250
+ ### Cross-type conversion in action
251
+
252
+ Set up two types with identical structural shape:
253
+
254
+ ```scala
255
+ import zio.blocks.schema.Schema
256
+ import zio.blocks.schema.SchemaError
257
+
258
+ case class Person(name: String, age: Int)
259
+ object Person {
260
+ implicit val schema: Schema[Person] = Schema.derived[Person]
261
+ }
262
+
263
+ case class Employee(name: String, age: Int)
264
+ object Employee {
265
+ implicit val schema: Schema[Employee] = Schema.derived[Employee]
266
+ }
267
+
268
+ val personSchema = Schema.derived[Person]
269
+ val employeeSchema = Schema.derived[Employee]
270
+ ```
271
+
272
+ Now encode a `Person` to `DynamicValue` and decode it as an `Employee`:
273
+
274
+ ```scala
275
+ val person = Person("Alice", 30)
276
+ // person: Person = Person(name = "Alice", age = 30)
277
+ val dynamic = personSchema.toDynamicValue(person)
278
+ // dynamic: DynamicValue = Record(
279
+ // IndexedSeq(("name", Primitive(String("Alice"))), ("age", Primitive(Int(30))))
280
+ // )
281
+
282
+ val employee: Either[SchemaError, Employee] =
283
+ employeeSchema.fromDynamicValue(dynamic)
284
+ // employee: Either[SchemaError, Employee] = Right(
285
+ // Employee(name = "Alice", age = 30)
286
+ // )
287
+ ```
288
+
289
+ The structural shape guarantee ensures type-safe conversion: at compile time, you know both schemas accept the same fields, so round-tripping through `DynamicValue` is safe and zero-cost.
290
+
291
+ ## Integration
292
+
293
+ Structural types integrate seamlessly with ZIO Blocks' broader ecosystem:
294
+
295
+ ### With Schema Evolution Macros
296
+
297
+ Structural schemas work with [Schema Evolution](./schema-evolution/into.md) macros for cross-type conversion. When two types share the same structural shape, the conversion machinery can work across type boundaries:
298
+
299
+ ```scala
300
+ import zio.blocks.schema.Schema
301
+
302
+ case class Person(name: String, age: Int)
303
+ object Person {
304
+ implicit val schema: Schema[Person] = Schema.derived[Person]
305
+ }
306
+
307
+ case class PersonDTO(name: String, age: Int)
308
+ object PersonDTO {
309
+ implicit val schema: Schema[PersonDTO] = Schema.derived[PersonDTO]
310
+ }
311
+
312
+ // Both types have identical structural schemas
313
+ val personSchema = Schema.derived[Person]
314
+ val dtoSchema = Schema.derived[PersonDTO]
315
+
316
+ // They share the same structural shape:
317
+ // Schema[{ def name: String; def age: Int }]
318
+ ```
319
+
320
+ ### With Binding.of (Serialization)
321
+
322
+ Structural types are also supported by the `Binding.of` macro for high-performance serialization via register-based encoding:
323
+
324
+ ```scala
325
+ import zio.blocks.schema.binding.Binding
326
+
327
+ // Direct structural type serialization (JVM only)
328
+ val binding = Binding.of[{ def name: String; def age: Int }]
329
+
330
+ // Works with nested structural types
331
+ val nestedBinding = Binding.of[{
332
+ def name: String
333
+ def address: { def street: String; def city: String }
334
+ }]
335
+
336
+ // Works with containers
337
+ val containerBinding = Binding.of[{
338
+ def name: String
339
+ def emails: List[String]
340
+ }]
341
+ ```
342
+
343
+ This enables anonymous structural types to benefit from ZIO Blocks' high-performance serialization without requiring nominal case class definitions. Like `Schema#structural`, this is **JVM-only**.
344
+
345
+ See [Binding](./binding.md) for detailed serialization documentation.
346
+
347
+ ## Running the Examples
348
+
349
+ Example applications demonstrating structural types are available in `schema-examples`:
350
+
351
+ ```sh
352
+ # Simple product type
353
+ sbt "schema-examples/runMain structural.StructuralSimpleProductExample"
354
+
355
+ # Nested products
356
+ sbt "schema-examples/runMain structural.StructuralNestedProductExample"
357
+
358
+ # Sealed trait (Scala 3)
359
+ sbt "schema-examples/runMain structural.StructuralSealedTraitExample"
360
+
361
+ # Enum (Scala 3)
362
+ sbt "schema-examples/runMain structural.StructuralEnumExample"
363
+
364
+ # Tuples
365
+ sbt "schema-examples/runMain structural.StructuralTupleExample"
366
+
367
+ # Integration with Into macro
368
+ sbt "schema-examples/runMain structural.StructuralIntoExample"
369
+ ```