Skip to content

Commit 7b75a2f

Browse files
committed
Limit structural schema derivation to JVM only
1 parent 3130d0a commit 7b75a2f

File tree

9 files changed

+138
-1204
lines changed

9 files changed

+138
-1204
lines changed

docs/reference/structural-schemas.md

Lines changed: 23 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,13 @@ ZIO Blocks extends `Schema[A]` to support structural types through:
2323
| Feature | Description |
2424
|---------|-------------|
2525
| `.structural` | Convert any product schema to its structural equivalent |
26-
| `Schema.derived[StructuralType]` | Derive schemas directly for structural types |
26+
| `Schema.derived[StructuralType]` | Derive schemas directly for structural types (JVM only) |
2727
| Normalized type names | Deterministic, comparable type identifiers |
2828

29+
:::caution JVM Only
30+
Structural type support requires reflection and is only available on the JVM. Attempting to use structural types on Scala.js or Scala Native will result in a compile-time error.
31+
:::
32+
2933
## Converting to Structural Schemas
3034

3135
Any schema for a product type (case class, tuple) can be converted to its structural representation:
@@ -42,10 +46,6 @@ val structuralSchema = nominalSchema.structural
4246
// Note: fields are alphabetically ordered in the structural type
4347
```
4448

45-
:::note JVM Requirement
46-
The `.structural` method requires JVM because it uses reflection to create the structural type at runtime. For cross-platform code, derive schemas for Selectable/Dynamic types directly instead.
47-
:::
48-
4949
### Type Name Normalization
5050

5151
Structural type names are normalized for consistent comparison:
@@ -101,84 +101,24 @@ val structural = Schema.derived[Container].structural
101101

102102
## Deriving Schemas for Structural Types
103103

104-
You can derive schemas directly for structural types without starting from a nominal type.
105-
106-
### Scala 3: Selectable-Based Types (Cross-Platform)
107-
108-
For cross-platform support, define a base class extending `Selectable`:
109-
110-
```scala
111-
import scala.Selectable
112-
113-
case class Record(fields: Map[String, Any]) extends Selectable {
114-
def selectDynamic(name: String): Any = fields(name)
115-
}
116-
117-
// Define structural types as refinements
118-
type PersonLike = Record { def name: String; def age: Int }
119-
type PointLike = Record { def x: Int; def y: Int }
120-
121-
// Derive schema directly
122-
val personSchema = Schema.derived[PersonLike]
123-
val pointSchema = Schema.derived[PointLike]
124-
```
125-
126-
**Requirements for Selectable types:**
127-
1. Base class must extend `Selectable`
128-
2. Must have either:
129-
- A constructor taking `Map[String, Any]`, or
130-
- A companion `apply(Map[String, Any]): T` method
131-
132-
### Scala 2: Dynamic-Based Types (Cross-Platform)
133-
134-
For Scala 2 cross-platform support, use `scala.Dynamic`:
135-
136-
```scala
137-
import scala.language.dynamics
138-
139-
class DynamicRecord(val fields: Map[String, Any]) extends Dynamic {
140-
def selectDynamic(name: String): Any = fields(name)
141-
}
142-
143-
object DynamicRecord {
144-
def apply(map: Map[String, Any]): DynamicRecord = new DynamicRecord(map)
145-
}
146-
147-
// Define structural types
148-
type PersonLike = DynamicRecord { def name: String; def age: Int }
149-
150-
// Derive schema
151-
val schema = Schema.derived[PersonLike]
152-
```
153-
154-
### Pure Structural Types (Scala 2: All Platforms, Scala 3: JVM Only)
155-
156-
In Scala 2, you can derive schemas for pure structural types on **all platforms** because the macro generates an anonymous Dynamic class at compile time:
104+
You can derive schemas directly for structural types. This feature requires JVM because it uses reflection for deconstruction.
157105

158106
```scala
159-
// Scala 2 - Works on JVM, JS, and Native!
107+
// JVM only - uses reflection
160108
type PersonLike = { def name: String; def age: Int }
161109
val schema = Schema.derived[PersonLike]
162-
163-
// The macro generates an anonymous Dynamic class, no runtime reflection needed
164110
```
165111

166-
In Scala 3, pure structural types without a `Selectable` base only work on JVM:
112+
:::caution Platform Restriction
113+
Structural type derivation requires reflection and only works on the JVM. Attempting to derive a schema for a structural type on JS or Native will result in a compile-time error:
167114

168-
```scala
169-
// Scala 3 JVM only - uses reflection
170-
type PointLike = { def x: Int; def y: Int }
171-
val schema = Schema.derived[PointLike] // Only works on JVM
172115
```
116+
Cannot derive Schema for structural type '...' on JS/Native.
173117
174-
:::caution Platform Restriction
175-
In Scala 3, pure structural types (without `Selectable`) require reflection and only work on the JVM. Attempting to derive a schema for a pure structural type on JS or Native will result in a compile-time error.
176-
177-
In Scala 2, pure structural types work on all platforms because the macro generates Dynamic classes at compile time.
178-
:::
118+
Structural types require reflection which is only available on JVM.
179119
180-
:::note Converting Nominal to Structural
181-
The `.structural` method on `Schema` (for converting `Schema[CaseClass]` to its structural equivalent) requires JVM on **both** Scala 2 and Scala 3 because it uses reflection to create the structural type.
120+
Consider using a case class instead.
121+
```
182122
:::
183123

184124
## Sum Types and Union Types
@@ -219,30 +159,7 @@ val schema = Schema.derived[Status]
219159

220160
### Scala 2: Sum Type Limitation
221161

222-
In Scala 2, sealed traits cannot be converted to structural types because Scala 2 lacks union types. Attempting to call `.structural` on a sealed trait schema will produce a compile-time error:
223-
224-
```scala
225-
// Scala 2
226-
sealed trait Status
227-
case object Active extends Status
228-
case object Inactive extends Status
229-
230-
val schema = Schema.derived[Status] // ✅ Works
231-
val structural = schema.structural // ❌ Compile error
232-
```
233-
234-
**Error message:**
235-
```
236-
Cannot convert sum type 'Status' to structural type.
237-
238-
Sum types (sealed traits, enums) cannot be represented as structural types in Scala 2
239-
because Scala 2 does not support union types.
240-
241-
Consider:
242-
- Using a case class wrapper instead of a sealed trait
243-
- Using the nominal schema directly
244-
- Upgrading to Scala 3 for union type support
245-
```
162+
In Scala 2, sealed traits cannot be converted to structural types because Scala 2 lacks union types. Attempting to call `.structural` on a sealed trait schema will produce a compile-time error.
246163

247164
## Platform Compatibility
248165

@@ -254,9 +171,7 @@ Consider:
254171
| Large Products (>22 fields) |||||
255172
| Sealed Trait `.structural` | ❌ (no unions) | ❌ (no unions) || ❌ (needs reflection) |
256173
| Enum `.structural` | N/A | N/A || ❌ (needs reflection) |
257-
| Pure Structural Derivation || ✅ (Dynamic) | ✅ (JVM only) ||
258-
| Dynamic-based Derivation ||| N/A | N/A |
259-
| Selectable-based Derivation | N/A | N/A |||
174+
| Pure Structural Derivation || ❌ (needs reflection) || ❌ (needs reflection) |
260175

261176
**Key:**
262177
- **`.structural`** = Converting `Schema[NominalType]` to `Schema[StructuralType]`
@@ -314,16 +229,12 @@ Structural schemas work seamlessly with the `Into` and `As` type classes for sch
314229
```scala
315230
import zio.blocks.schema._
316231

317-
// Define Selectable base for cross-platform support
318-
case class Record(fields: Map[String, Any]) extends Selectable {
319-
def selectDynamic(name: String): Any = fields(name)
320-
}
321-
322-
type PersonLike = Record { def name: String; def age: Int }
232+
// JVM only
233+
type PersonLike = { def name: String; def age: Int }
323234

324235
case class Person(name: String, age: Int)
325236

326-
// Convert between nominal and structural
237+
// Convert between nominal and structural (JVM only)
327238
val personToStructural = Into.derived[Person, PersonLike]
328239
val structuralToPerson = Into.derived[PersonLike, Person]
329240

@@ -335,41 +246,19 @@ See [Schema Evolution](schema-evolution.md) for complete documentation on `Into`
335246

336247
## Complete Example
337248

338-
Here's a complete example demonstrating structural schema features:
249+
Here's a complete example demonstrating structural schema features (JVM only):
339250

340251
```scala
341252
import zio.blocks.schema._
342-
import scala.Selectable
343253

344-
// === Define a cross-platform Selectable base ===
345-
case class Record(fields: Map[String, Any]) extends Selectable {
346-
def selectDynamic(name: String): Any = fields(name)
347-
}
348-
349-
// === Define structural types as refinements ===
350-
type PersonLike = Record { def name: String; def age: Int }
351-
type AddressLike = Record { def street: String; def city: String }
254+
// === Define structural types ===
255+
type PersonLike = { def name: String; def age: Int }
256+
type AddressLike = { def street: String; def city: String }
352257

353-
// === Derive schemas for structural types ===
258+
// === Derive schemas for structural types (JVM only) ===
354259
val personSchema = Schema.derived[PersonLike]
355260
val addressSchema = Schema.derived[AddressLike]
356261

357-
// === Create instances using the Record constructor ===
358-
def makePerson(name: String, age: Int): PersonLike =
359-
Record(Map("name" -> name, "age" -> age)).asInstanceOf[PersonLike]
360-
361-
val alice = makePerson("Alice", 30)
362-
363-
// Access fields via structural type refinement
364-
println(alice.name) // "Alice"
365-
println(alice.age) // 30
366-
367-
// === Convert to/from DynamicValue ===
368-
val dynamic = personSchema.toDynamicValue(alice)
369-
// DynamicValue.Record(Vector("name" -> Primitive(String("Alice")), "age" -> Primitive(Int(30))))
370-
371-
val restored = personSchema.fromDynamicValue(dynamic)
372-
// Right(Record(Map("name" -> "Alice", "age" -> 30)))
373262

374263
// === Check structural type name ===
375264
personSchema.reflect.typeName.name

schema/js/src/test/scala/zio/blocks/schema/into/structural/StructuralTypeCompileErrorSpec.scala

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ import zio.test._
55
/**
66
* Tests for structural type conversions on Scala.js.
77
*
8-
* - Structural → Product: Requires reflection, fails at compile time on JS
8+
* - Structural types require reflection and fail at compile time on JS
99
*/
1010
object StructuralTypeCompileErrorSpec extends ZIOSpecDefault {
1111

1212
def spec = suite("StructuralTypeCompileErrorSpec")(
13-
suite("Structural to Product - Compile Error on JS")(
13+
suite("Structural types - Compile Error on JS")(
1414
test("structural type to case class fails to compile") {
1515
typeCheck {
1616
"""
@@ -24,8 +24,8 @@ object StructuralTypeCompileErrorSpec extends ZIOSpecDefault {
2424
}.map { result =>
2525
assertTrue(
2626
result.isLeft,
27-
// Structural type conversions require reflection, not supported on JS
28-
result.swap.exists(_.toLowerCase.contains("structural type conversions are not supported on js"))
27+
// Structural types require reflection, not supported on JS
28+
result.swap.exists(_.toLowerCase.contains("structural types require reflection"))
2929
)
3030
}
3131
},
@@ -42,7 +42,7 @@ object StructuralTypeCompileErrorSpec extends ZIOSpecDefault {
4242
}.map { result =>
4343
assertTrue(
4444
result.isLeft,
45-
result.swap.exists(_.toLowerCase.contains("structural type conversions are not supported on js"))
45+
result.swap.exists(_.toLowerCase.contains("structural types require reflection"))
4646
)
4747
}
4848
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package zio.blocks.schema.structural
2+
3+
import zio.blocks.schema._
4+
import zio.test._
5+
6+
/**
7+
* Tests for Scala 2 pure structural type derivation (JVM only).
8+
*/
9+
object StructuralTypeSpec extends ZIOSpecDefault {
10+
11+
type PersonLike = { def name: String; def age: Int }
12+
type PointLike = { def x: Int; def y: Int }
13+
14+
def spec = suite("StructuralTypeSpec")(
15+
test("structural type round-trips through DynamicValue") {
16+
val schema = Schema.derived[PersonLike]
17+
val dynamic = DynamicValue.Record(
18+
Vector(
19+
"name" -> DynamicValue.Primitive(PrimitiveValue.String("Alice")),
20+
"age" -> DynamicValue.Primitive(PrimitiveValue.Int(30))
21+
)
22+
)
23+
val result = schema.fromDynamicValue(dynamic)
24+
result match {
25+
case Right(person) =>
26+
val backToDynamic = schema.toDynamicValue(person)
27+
backToDynamic match {
28+
case rec: DynamicValue.Record =>
29+
val expected = dynamic.asInstanceOf[DynamicValue.Record]
30+
assertTrue(
31+
rec.fields.toSet == expected.fields.toSet,
32+
person.name == "Alice",
33+
person.age == 30
34+
)
35+
case _ => assertTrue(false) ?? "Expected DynamicValue.Record"
36+
}
37+
case Left(err) =>
38+
assertTrue(false) ?? s"fromDynamicValue failed: $err"
39+
}
40+
},
41+
test("structural type with primitives round-trips") {
42+
val schema = Schema.derived[PointLike]
43+
val dynamic = DynamicValue.Record(
44+
Vector(
45+
"x" -> DynamicValue.Primitive(PrimitiveValue.Int(100)),
46+
"y" -> DynamicValue.Primitive(PrimitiveValue.Int(200))
47+
)
48+
)
49+
val result = schema.fromDynamicValue(dynamic)
50+
result match {
51+
case Right(point) =>
52+
val backToDynamic = schema.toDynamicValue(point)
53+
backToDynamic match {
54+
case rec: DynamicValue.Record =>
55+
val expected = dynamic.asInstanceOf[DynamicValue.Record]
56+
assertTrue(
57+
rec.fields.toSet == expected.fields.toSet,
58+
point.x == 100,
59+
point.y == 200
60+
)
61+
case _ => assertTrue(false) ?? "Expected DynamicValue.Record"
62+
}
63+
case Left(err) =>
64+
assertTrue(false) ?? s"fromDynamicValue failed: $err"
65+
}
66+
}
67+
)
68+
}

schema/native/src/test/scala/zio/blocks/schema/into/structural/StructuralTypeCompileErrorSpec.scala

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ package zio.blocks.schema.into.structural
33
import zio.test._
44

55
/**
6-
* Tests that structural type conversions fail at compile time on Scala Native.
6+
* Tests that structural types fail at compile time on Scala Native.
77
*/
88
object StructuralTypeCompileErrorSpec extends ZIOSpecDefault {
99

1010
def spec = suite("StructuralTypeCompileErrorSpec")(
11-
suite("Structural to Product - Compile Error on Native")(
11+
suite("Structural types - Compile Error on Native")(
1212
test("structural type to case class fails to compile") {
1313
typeCheck {
1414
"""
@@ -22,8 +22,8 @@ object StructuralTypeCompileErrorSpec extends ZIOSpecDefault {
2222
}.map { result =>
2323
assertTrue(
2424
result.isLeft,
25-
// Structural type conversions require reflection, not supported on Native
26-
result.swap.exists(_.toLowerCase.contains("structural type conversions are not supported on native"))
25+
// Structural types require reflection, not supported on Native
26+
result.swap.exists(_.toLowerCase.contains("structural types require reflection"))
2727
)
2828
}
2929
},
@@ -40,7 +40,7 @@ object StructuralTypeCompileErrorSpec extends ZIOSpecDefault {
4040
}.map { result =>
4141
assertTrue(
4242
result.isLeft,
43-
result.swap.exists(_.toLowerCase.contains("structural type conversions are not supported on native"))
43+
result.swap.exists(_.toLowerCase.contains("structural types require reflection"))
4444
)
4545
}
4646
}

0 commit comments

Comments
 (0)