Skip to main content
Version: 2.x

SchemaError

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 Exception so 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)
SubtypeFactoryTypical cause
ConversionFailedconversionFailed, validationFailedType conversion or smart-constructor failure; may carry a nested SchemaError as cause
MissingFieldmissingFieldRequired field absent in the decoded representation
DuplicatedFieldduplicatedFieldSame field key appears more than once
ExpectationMismatchexpectationMismatchWrong DynamicValue variant encountered
UnknownCaseunknownCaseDiscriminator names an unrecognised sealed-trait variant
Messagemessage, applyFree-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}

implicit val intSchema: Schema[Int] = Schema[Int]
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.