Structural Types
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.
Motivation​
Consider a common integration scenario:
// Your system
case class Person(name: String, age: Int)
// External system (same data, different class)
case class User(name: String, age: Int)
Without structural types, converting between Person and User requires manual translation. With structural types, they both have the same structural schema:
import scala.language.reflectiveCalls
import zio.blocks.schema.Schema
case class Person(name: String, age: Int)
case class User(name: String, age: Int)
val personSchema = Schema.derived[Person]
val personStructural = personSchema.structural
// Schema[{ def name: String; def age: Int }]
val userSchema = Schema.derived[User]
val userStructural = userSchema.structural
// Schema[{ def name: String; def age: Int }]
// Both schemas accept the same data shape
Construction: Schema#structural​
Use the Schema#structural method on any schema to get the corresponding structural schema.
Scala 3: Using transparent inline — the return type is inferred to the full refinement type:
import zio.blocks.schema.Schema
case class Person(name: String, age: Int)
object Person {
implicit val schema: Schema[Person] = Schema.derived[Person]
}
val personSchema: Schema[Person] = Schema.derived[Person]
val structuralSchema: Schema[{ def name: String; def age: Int }] = personSchema.structural
Scala 2: Implicit derivation — returns Schema[ts.StructuralType] (path-dependent type):
import zio.blocks.schema.Schema
case class Person(name: String, age: Int)
object Person {
implicit val schema: Schema[Person] = Schema.derived[Person]
}
val personSchema: Schema[Person] = Schema.derived[Person]
val structuralSchema = personSchema.structural
// Type: Schema[ts.StructuralType] (structural type inferred from macro)
Supported Conversions​
The following type categories can be converted to structural schemas:
Product Types (Case Classes)​
Both Scala 2 and 3 support structural conversion of case classes:
import zio.blocks.schema.Schema
case class Address(street: String, city: String, zipCode: Int)
object Address {
implicit val schema: Schema[Address] = Schema.derived[Address]
}
val schema = Schema.derived[Address]
val structural = schema.structural
// Schema[{ def street: String; def city: String; def zipCode: Int }]
Tuples​
Tuples convert to structural records with field names derived from positions:
import zio.blocks.schema.Schema
type StringIntBool = (String, Int, Boolean)
implicit val schema: Schema[StringIntBool] = Schema.derived[StringIntBool]
val tupleSchema = Schema.derived[(String, Int, Boolean)]
val structuralSchema = tupleSchema.structural
// Schema[{ def _1: String; def _2: Int; def _3: Boolean }]
Nested Products​
Nested product fields keep their nominal types; only the outer product is structuralized:
import zio.blocks.schema.Schema
case class Address(street: String, city: String)
object Address {
implicit val schema: Schema[Address] = Schema.derived[Address]
}
case class Person(name: String, age: Int, address: Address)
object Person {
implicit val schema: Schema[Person] = Schema.derived[Person]
}
val personSchema = Schema.derived[Person]
val structuralSchema = personSchema.structural
// Schema[{
// def name: String
// def age: Int
// def address: Address
// }]
Opaque Types (Scala 3)​
Opaque type aliases are unwrapped to their underlying type:
import zio.blocks.schema.Schema
opaque type UserId = String
case class User(id: UserId, name: String)
object User {
implicit val schema: Schema[User] = Schema.derived[User]
}
val schema = Schema.derived[User]
val structural = schema.structural
// Schema[{ def id: String; def name: String }]
// (UserId unwrapped to String)
Sum Types / Sealed Traits (Scala 3)​
Sealed traits and enums convert to union types with nested method syntax:
import zio.blocks.schema.Schema
sealed trait Shape
object Shape {
case class Circle(radius: Double) extends Shape
case class Rectangle(width: Double, height: Double) extends Shape
implicit val schema: Schema[Shape] = Schema.derived[Shape]
}
val schema = Schema.derived[Shape]
val structural = schema.structural
// Schema[
// { def Circle: { def radius: Double } } |
// { def Rectangle: { def height: Double; def width: Double } }
// ]
// (cases sorted alphabetically)
Enum syntax (Scala 3):
import zio.blocks.schema.Schema
enum Color {
case Red, Green, Blue
}
object Color {
implicit val schema: Schema[Color] = Schema.derived[Color]
}
val schema = Schema.derived[Color]
val structural = schema.structural
// Schema[
// { def Blue: {} } |
// { def Green: {} } |
// { def Red: {} }
// ]
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.
Direct Structural Derivation (Scala 3)​
Create a schema directly for a structural type without a nominal base:
import zio.blocks.schema.Schema
// No case class needed — define the schema for the shape directly
val personStructural = Schema.derived[{ def name: String; def age: Int }]
// The schema is ready to use with values matching that structural shape
This is only supported in Scala 3 with the right macro machinery.
Round-tripping Through DynamicValue​
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.
Motivation​
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.
Common scenarios:
- API gateways — receive a
PersonDTOfrom an external API, decode as your internalPersontype - Message brokers — consume an event shaped like
UserEvent, convert to your domainAccounttype - Data pipelines — records with identical fields but different class names from different services
Cross-type conversion in action​
Set up two types with identical structural shape:
import zio.blocks.schema.Schema
import zio.blocks.schema.SchemaError
case class Person(name: String, age: Int)
object Person {
implicit val schema: Schema[Person] = Schema.derived[Person]
}
case class Employee(name: String, age: Int)
object Employee {
implicit val schema: Schema[Employee] = Schema.derived[Employee]
}
val personSchema = Schema.derived[Person]
val employeeSchema = Schema.derived[Employee]
Now encode a Person to DynamicValue and decode it as an Employee:
val person = Person("Alice", 30)
// person: Person = Person(name = "Alice", age = 30)
val dynamic = personSchema.toDynamicValue(person)
// dynamic: DynamicValue = Record(
// IndexedSeq(("name", Primitive(String("Alice"))), ("age", Primitive(Int(30))))
// )
val employee: Either[SchemaError, Employee] =
employeeSchema.fromDynamicValue(dynamic)
// employee: Either[SchemaError, Employee] = Right(
// Employee(name = "Alice", age = 30)
// )
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.
Integration​
Structural types integrate seamlessly with ZIO Blocks' broader ecosystem:
With Schema Evolution Macros​
Structural schemas work with Schema Evolution macros for cross-type conversion. When two types share the same structural shape, the conversion machinery can work across type boundaries:
import zio.blocks.schema.Schema
case class Person(name: String, age: Int)
object Person {
implicit val schema: Schema[Person] = Schema.derived[Person]
}
case class PersonDTO(name: String, age: Int)
object PersonDTO {
implicit val schema: Schema[PersonDTO] = Schema.derived[PersonDTO]
}
// Both types have identical structural schemas
val personSchema = Schema.derived[Person]
val dtoSchema = Schema.derived[PersonDTO]
// They share the same structural shape:
// Schema[{ def name: String; def age: Int }]
With Binding.of (Serialization)​
Structural types are also supported by the Binding.of macro for high-performance serialization via register-based encoding:
import zio.blocks.schema.binding.Binding
// Direct structural type serialization (JVM only)
val binding = Binding.of[{ def name: String; def age: Int }]
// Works with nested structural types
val nestedBinding = Binding.of[{
def name: String
def address: { def street: String; def city: String }
}]
// Works with containers
val containerBinding = Binding.of[{
def name: String
def emails: List[String]
}]
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.
See Binding for detailed serialization documentation.
Running the Examples​
Example applications demonstrating structural types are available in schema-examples:
# Simple product type
sbt "schema-examples/runMain structural.StructuralSimpleProductExample"
# Nested products
sbt "schema-examples/runMain structural.StructuralNestedProductExample"
# Sealed trait (Scala 3)
sbt "schema-examples/runMain structural.StructuralSealedTraitExample"
# Enum (Scala 3)
sbt "schema-examples/runMain structural.StructuralEnumExample"
# Tuples
sbt "schema-examples/runMain structural.StructuralTupleExample"
# Integration with Into macro
sbt "schema-examples/runMain structural.StructuralIntoExample"