schema-error.md
April 12, 2026 · View on GitHub
SchemaError is a structured error type for schema operations in ZIO Blocks. It represents one or more validation, conversion, or structural failures that occurred while decoding, encoding, or transforming data, each annotated with a DynamicOptic path that pinpoints the failing location in the data structure.
final case class SchemaError(errors: ::[SchemaError.Single])
extends Exception with NoStackTrace
Here is the full structure of SchemaError:
SchemaError
└── errors: ::[Single] (non-empty list — always at least one failure)
│
└── Single (sealed trait)
│ ├── source: DynamicOptic (path to the failing location)
│ └── message: String (human-readable description)
│
├── ConversionFailed (type or value conversion failed)
├── MissingField (required field absent)
├── DuplicatedField (same field key appears more than once)
├── ExpectationMismatch (wrong DynamicValue variant encountered)
├── UnknownCase (unrecognised sealed-trait discriminator)
└── Message (free-form message, optional path)
SchemaError:
- Aggregates multiple independent failures into a single error value
- Annotates every failure with a precise traversal path through the data
- Extends
Exceptionso it can be thrown and caught with standard JVM mechanisms - Suppresses stack traces via
NoStackTrace— error location is conveyed through the path, not the JVM stack
Motivation
When decoding a complex nested value, a single structural problem — a missing field, a type mismatch, an unknown case discriminator — must be reported together with the location where it occurred. In a large schema, multiple independent problems can coexist, and surfacing them all at once saves the caller round-trips.
Every Schema#fromDynamicValue, every Codec#decode, and every optic traversal that can fail returns Either[SchemaError, A]. The same type carries both structural errors (missing fields, wrong types) and domain validation errors (value out of range, blank string), so callers deal with a single error channel.
SchemaError was introduced to replace the earlier JsonError and DynamicValueError types that existed as separate error channels for each format. Those types used string concatenation ("error1; error2") to combine failures via ++, which silently discarded path information from the second error onward. SchemaError solves this by maintaining a non-empty list (::) of Single failures — each one independently annotated with its own DynamicOptic path — so no information is lost during aggregation.
Here is a quick taste of how SchemaError behaves:
import zio.blocks.schema.SchemaError
// Create a simple message error
val err = SchemaError("Age must be positive")
// Annotate with the location in the data
val located = SchemaError.missingField(Nil, "email").atField("user")
println(located.message) // Missing field 'email' at: .user
// Combine two independent failures
val combined = SchemaError("name is blank") ++ SchemaError("age is negative")
println(combined.errors.length) // 2
Construction / Creating Instances
SchemaError.apply
The simplest constructor — creates a free-form Message error at the root path:
object SchemaError {
def apply(details: String): SchemaError
}
import zio.blocks.schema.SchemaError
val err = SchemaError("Value must be positive")
println(err.message) // Value must be positive
SchemaError.message
Creates a Message error with a free-form description and an optional DynamicOptic path. When no path is supplied it defaults to the root.
object SchemaError {
def message(details: String, path: DynamicOptic = DynamicOptic.root): SchemaError
}
Here we create a message error at the root, and another with an explicit path:
import zio.blocks.schema.{DynamicOptic, SchemaError}
// Root-level message (same as SchemaError.apply)
val atRoot = SchemaError.message("Unexpected null")
println(atRoot.message) // Unexpected null
// Message with an explicit path
val path = DynamicOptic.root.field("address")
val atPath = SchemaError.message("Unexpected null", path)
println(atPath.message) // Unexpected null at: .address
SchemaError.validationFailed
Convenience factory for validation failures. Equivalent to SchemaError.conversionFailed(Nil, message), designed for smart constructors that return string-based error messages.
object SchemaError {
def validationFailed(message: String): SchemaError
}
Here is an example:
import zio.blocks.schema.SchemaError
val err = SchemaError.validationFailed("Age must be between 0 and 150")
println(err.message) // Age must be between 0 and 150
SchemaError.conversionFailed
Creates a ConversionFailed error for a failed type or value conversion. Two overloads exist.
object SchemaError {
def conversionFailed(trace: List[DynamicOptic.Node], details: String): SchemaError
def conversionFailed(contextMessage: String, cause: SchemaError): SchemaError
}
The first overload is used by codecs — trace is the list of path nodes accumulated during decoding. Pass Nil when constructing an error manually and use the at* methods to set the path. The second overload wraps a nested SchemaError with additional context; the nested failures are rendered under a "Caused by:" section.
import zio.blocks.schema.SchemaError
// Root-level conversion failure
val err = SchemaError.conversionFailed(Nil, "Expected a positive integer")
println(err.message) // Expected a positive integer
// Wrapping a nested failure with context
val inner = SchemaError("name must not be empty") ++
SchemaError("age must be positive")
val outer = SchemaError.conversionFailed("Person construction failed", inner)
println(outer.message)
// Person construction failed
// Caused by:
// - name must not be empty
// - age must be positive
SchemaError.missingField
Creates a MissingField error indicating that a required field was absent from the decoded representation.
object SchemaError {
def missingField(trace: List[DynamicOptic.Node], fieldName: String): SchemaError
}
import zio.blocks.schema.SchemaError
val err = SchemaError.missingField(Nil, "email").atField("user")
println(err.message) // Missing field 'email' at: .user
SchemaError.duplicatedField
Creates a DuplicatedField error indicating that the same field key appeared more than once in the encoded form.
object SchemaError {
def duplicatedField(trace: List[DynamicOptic.Node], fieldName: String): SchemaError
}
import zio.blocks.schema.SchemaError
val err = SchemaError.duplicatedField(Nil, "id").atField("record")
println(err.message) // Duplicated field 'id' at: .record
SchemaError.expectationMismatch
Creates an ExpectationMismatch error indicating that the encountered DynamicValue variant does not match what the schema expected.
object SchemaError {
def expectationMismatch(trace: List[DynamicOptic.Node], expectation: String): SchemaError
}
import zio.blocks.schema.SchemaError
val err = SchemaError
.expectationMismatch(Nil, "Expected Record, got Sequence")
.atField("data")
println(err.message) // Expected Record, got Sequence at: .data
SchemaError.unknownCase
Creates an UnknownCase error indicating that the decoded discriminator value does not correspond to any known variant of a sealed trait.
object SchemaError {
def unknownCase(trace: List[DynamicOptic.Node], caseName: String): SchemaError
}
import zio.blocks.schema.SchemaError
val err = SchemaError.unknownCase(Nil, "Triangle").atField("shape")
println(err.message) // Unknown case 'Triangle' at: .shape
Core Operations
Error Messages
message
Returns all individual error messages joined with newlines.
final case class SchemaError(errors: ::[SchemaError.Single]) {
def message: String
}
import zio.blocks.schema.SchemaError
val err = SchemaError("first failure") ++ SchemaError("second failure")
println(err.message)
// first failure
// second failure
getMessage
Delegates to message. Because SchemaError extends Exception, getMessage is called by the JVM when the exception is printed or logged by frameworks.
final case class SchemaError(errors: ::[SchemaError.Single]) {
def getMessage: String
}
import zio.blocks.schema.SchemaError
val err = SchemaError("something went wrong")
assert(err.getMessage == err.message)
Error Aggregation
++
Combines two SchemaError values into one, preserving all individual Single failures from both sides. We use ++ to accumulate errors from independent parts of a schema — for example, multiple record fields that are each decoded independently.
final case class SchemaError(errors: ::[SchemaError.Single]) {
def ++(other: SchemaError): SchemaError
}
import zio.blocks.schema.SchemaError
val nameError = SchemaError.missingField(Nil, "name")
val ageError = SchemaError.conversionFailed(Nil, "Age must be positive")
val combined = nameError ++ ageError
println(combined.errors.length) // 2
println(combined.message)
// Missing field 'name' at: .
// Age must be positive
++ is associative: (a ++ b) ++ c and a ++ (b ++ c) produce the same set of errors.
Path Annotation
Path annotation methods prepend a path segment to the source of every SchemaError.Single inside the error. Codecs call these methods as they unwind the call stack — the innermost call adds the innermost path segment, and the outermost call adds the outermost one.
atField
Prepends a record field access to the path of all errors.
final case class SchemaError(errors: ::[SchemaError.Single]) {
def atField(name: String): SchemaError
}
import zio.blocks.schema.SchemaError
// Codec decoding 'city' inside 'address' inside 'user'
val err = SchemaError.missingField(Nil, "city")
.atField("address") // called by the address codec
.atField("user") // called by the user codec
println(err.message) // Missing field 'city' at: .user.address
atIndex
Prepends a sequence index access to the path of all errors.
final case class SchemaError(errors: ::[SchemaError.Single]) {
def atIndex(index: Int): SchemaError
}
import zio.blocks.schema.SchemaError
val err = SchemaError("invalid phone number").atIndex(2).atField("phones")
println(err.message) // invalid phone number at: .phones[2]
atCase
Prepends a sealed-trait case access to the path of all errors. In the compact path notation used by Message, the case name appears as <CaseName>.
final case class SchemaError(errors: ::[SchemaError.Single]) {
def atCase(name: String): SchemaError
}
import zio.blocks.schema.SchemaError
val err = SchemaError("conversion failed").atField("value").atCase("Right")
println(err.message) // conversion failed at: <Right>.value
atKey
Prepends a map key access to the path of all errors. The key is a DynamicValue, rendered with {key} in the compact path notation.
final case class SchemaError(errors: ::[SchemaError.Single]) {
def atKey(key: DynamicValue): SchemaError
}
import zio.blocks.schema.{DynamicValue, SchemaError}
val key = DynamicValue.string("config")
val err = SchemaError("missing required entry").atKey(key)
println(err.message) // missing required entry at: {"config"}
Path Chaining
All path methods can be chained. Each call prepends to the existing path, so the outermost call appears as the leftmost segment in the rendered message.
import zio.blocks.schema.SchemaError
val err = SchemaError("value out of range")
.atField("amount") // innermost — added first
.atIndex(0)
.atCase("Credit")
.atField("transactions") // outermost — added last
println(err.message)
// value out of range at: .transactions<Credit>[0].amount
Path annotation applies to every Single inside the error, so combined errors accumulate paths correctly:
import zio.blocks.schema.SchemaError
val error1 = SchemaError.missingField(Nil, "name")
val error2 = SchemaError.conversionFailed(Nil, "age must be positive")
val combined = (error1 ++ error2).atField("person")
// Both errors now include the "person" field prefix
println(combined.errors.head.source.nodes.nonEmpty) // true (name)
println(combined.errors.tail.head.source.nodes.nonEmpty) // true (age)
Subtypes / Variants
SchemaError.Single is the sealed base trait for every individual failure. Each variant carries a source: DynamicOptic and a message: String.
SchemaError.Single (sealed trait)
├── SchemaError.IntoError (sealed sub-trait — marks conversion errors)
│ └── ConversionFailed(source, details, cause: Option[SchemaError])
├── MissingField(source, fieldName)
├── DuplicatedField(source, fieldName)
├── ExpectationMismatch(source, expectation)
├── UnknownCase(source, caseName)
└── Message(source, details)
| Subtype | Factory | Typical cause |
|---|---|---|
ConversionFailed | conversionFailed, validationFailed | Type conversion or smart-constructor failure; may carry a nested SchemaError as cause |
MissingField | missingField | Required field absent in the decoded representation |
DuplicatedField | duplicatedField | Same field key appears more than once |
ExpectationMismatch | expectationMismatch | Wrong DynamicValue variant encountered |
UnknownCase | unknownCase | Discriminator names an unrecognised sealed-trait variant |
Message | message, apply | Free-form error with an optional path |
We can pattern match on errors to handle specific failure kinds:
import zio.blocks.schema.SchemaError
val err = SchemaError.missingField(Nil, "email") ++
SchemaError.conversionFailed(Nil, "age must be positive")
err.errors.foreach {
case SchemaError.MissingField(source, fieldName) =>
println(s"Missing '$fieldName' at ${source.toScalaString}")
case SchemaError.ConversionFailed(source, details, _) =>
println(s"Conversion failed: $details")
case other =>
println(other.message)
}
SchemaError.IntoError
IntoError is a sealed sub-trait of Single that marks errors produced during Into (type conversion) operations. Its only current implementation is ConversionFailed. Codec code pattern-matches on IntoError to distinguish conversion errors from structural schema errors:
sealed trait IntoError extends SchemaError.Single {
def source: DynamicOptic
}
SchemaError.ConversionFailed
Represents a failed type or value conversion. When a cause: Option[SchemaError] is present, the rendered message includes a "Caused by:" section showing the nested failures.
import zio.blocks.schema.SchemaError
// Single nested cause
val inner1 = SchemaError.conversionFailed(Nil, "name is blank")
val outer1 = SchemaError.conversionFailed("Person construction failed", inner1)
println(outer1.message)
// Person construction failed
// Caused by: name is blank
// Multiple nested causes
val inner2 = SchemaError.conversionFailed(Nil, "name is blank") ++
SchemaError.conversionFailed(Nil, "age is negative")
val outer2 = SchemaError.conversionFailed("Person construction failed", inner2)
println(outer2.message)
// Person construction failed
// Caused by:
// - name is blank
// - age is negative
Integration
With Schema Decoding
Schema#fromDynamicValue returns Either[SchemaError, A]. Every codec accumulates path nodes during decoding and calls atField, atIndex, or atCase as it unwinds, producing a fully-annotated SchemaError on failure.
import zio.blocks.schema.{Schema, SchemaError}
case class Person(name: String, age: Int)
object Person {
implicit val schema: Schema[Person] = Schema.derived
}
val result: Either[SchemaError, Person] =
Schema[Person].fromDynamicValue(Schema[Person].toDynamicValue(Person("Alice", 30)))
result match {
case Right(person) => println(s"Decoded: $person")
case Left(err) => println(s"Error:\n${err.message}")
}
See Schema and DynamicValue for the full encoding and decoding API.
With Schema#transform
Schema#transform accepts to and from functions that can throw SchemaError to signal validation failures during encoding or decoding. We use SchemaError.validationFailed (which wraps a ConversionFailed) to turn a smart-constructor rejection into a structured schema error:
import zio.blocks.schema.{Schema, SchemaError}
case class PositiveInt private (value: Int)
object PositiveInt {
def make(n: Int): PositiveInt =
if (n > 0) PositiveInt(n)
else throw SchemaError.validationFailed("must be positive")
implicit val schema: Schema[PositiveInt] =
Schema[Int].transform(make, _.value)
}
When make throws a SchemaError, the codec catches it, preserves the full error (including any path already annotated), and surfaces it as Left(schemaError) from Schema#fromDynamicValue or Codec#decode. See Schema for the full transform API.
A runnable version of this example, including composite types and error aggregation, is available in the schema-examples module:
sbt "schema-examples/runMain schemaerror.SchemaErrorExample"
With Validation
The Validation system uses SchemaError to report constraint violations. When a PrimitiveType carries a Validation and the value fails the check, the codec surfaces a SchemaError.ConversionFailed at the appropriate path.
With DynamicOptic and Optics
Path annotation (atField, atIndex, atKey, atCase) builds a DynamicOptic inside each error. Operations such as DynamicValue#setOrFail and DynamicValue#modifyAtPathOrFail return Either[SchemaError, DynamicValue], using the same factory methods.
import zio.blocks.schema.{DynamicOptic, DynamicValue, Schema, SchemaError}
val data = DynamicValue.Sequence(DynamicValue.int(1), DynamicValue.int(2))
val optic = DynamicOptic.root.at(10)
val result: Either[SchemaError, DynamicValue] = data.setOrFail(optic, DynamicValue.int(99))
result match {
case Left(err) => println(err.message) // index out of range or similar
case Right(v) => println(v)
}
As an Exception
Because SchemaError extends Exception, it can be thrown and caught with standard try/catch (In functional code, prefer Either[SchemaError, A] or Option[SchemaError] instead):
import zio.blocks.schema.SchemaError
try {
throw SchemaError("Unexpected data shape")
} catch {
case e: SchemaError => println(s"Caught schema error: ${e.getMessage}")
}
:::note
SchemaError extends scala.util.control.NoStackTrace. Stack traces are suppressed for performance — error location is conveyed through the DynamicOptic path inside each Single, not the JVM stack.
:::