Skip to main content
Version: 2.x

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 PersonDTO from an external API, decode as your internal Person type
  • Message brokers — consume an event shaped like UserEvent, convert to your domain Account type
  • 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"