Skip to content

Latest commit

 

History

History
458 lines (332 loc) · 14 KB

File metadata and controls

458 lines (332 loc) · 14 KB
id title
validation
Validation

Validation is a sealed trait in ZIO Blocks that represents declarative constraints on primitive values. Validations are attached to PrimitiveType instances and are checked during schema operations like decoding from DynamicValue or validating against a DynamicSchema.

Overview

The validation system in ZIO Blocks provides:

  • Declarative constraints on numeric and string values
  • Automatic enforcement during schema-based decoding
  • Integration with wrapper types via transform for custom validation logic
  • Schema error reporting with path information for debugging via SchemaError
Validation[A]
 ├── Validation.None                    (no constraint)
 │
 ├── Validation.Numeric[A]              (numeric constraints)
 │    ├── Positive                      (> 0)
 │    ├── Negative                      (< 0)
 │    ├── NonPositive                   (<= 0)
 │    ├── NonNegative                   (>= 0)
 │    ├── Range[A](min, max)            (within bounds)
 │    └── Set[A](values)                (one of specific values)
 │
 └── Validation.String                  (string constraints)
      ├── NonEmpty                      (length > 0)
      ├── Empty                         (length == 0)
      ├── Blank                         (whitespace only)
      ├── NonBlank                      (has non-whitespace)
      ├── Length(min, max)              (length bounds)
      └── Pattern(regex)                (regex match)

Built-in Validations

Numeric Validations

Numeric validations apply to Byte, Short, Int, Long, Float, Double, BigInt, and BigDecimal.

import zio.blocks.schema.Validation

// Sign constraints
Validation.Numeric.Positive     // value > 0
Validation.Numeric.Negative     // value < 0
Validation.Numeric.NonPositive  // value <= 0
Validation.Numeric.NonNegative  // value >= 0

// Range constraint (inclusive bounds)
Validation.Numeric.Range(Some(1), Some(100))   // 1 <= value <= 100
Validation.Numeric.Range(Some(0), None)        // value >= 0 (no upper bound)
Validation.Numeric.Range(None, Some(1000))     // value <= 1000 (no lower bound)

// Set constraint (value must be one of the specified values)
Validation.Numeric.Set(Set(1, 2, 3, 5, 8, 13))

String Validations

String validations apply to String primitive types.

import zio.blocks.schema.Validation

// Content constraints
Validation.String.NonEmpty   // string.nonEmpty (length > 0)
Validation.String.Empty      // string.isEmpty (length == 0)
Validation.String.Blank      // string.trim.isEmpty (whitespace only)
Validation.String.NonBlank   // string.trim.nonEmpty (has non-whitespace)

// Length constraint (inclusive bounds)
Validation.String.Length(Some(1), Some(255))   // 1 <= length <= 255
Validation.String.Length(Some(3), None)        // length >= 3
Validation.String.Length(None, Some(100))      // length <= 100

// Pattern constraint (regex)
Validation.String.Pattern("^[a-z]+$")          // lowercase letters only
Validation.String.Pattern("^\\d{5}$")          // exactly 5 digits

Validation with PrimitiveType

Validations are attached to PrimitiveType instances. Each primitive type carries its validation constraint:

import zio.blocks.schema.{PrimitiveType, Validation}

// Int with no validation
val intType = PrimitiveType.Int(Validation.None)

// Positive Int
val positiveIntType = PrimitiveType.Int(Validation.Numeric.Positive)

// Non-empty String
val nonEmptyStringType = PrimitiveType.String(Validation.String.NonEmpty)

// String matching email pattern
val emailPattern = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$"
val emailStringType = PrimitiveType.String(Validation.String.Pattern(emailPattern))

// Int in range 1-10
val rangeIntType = PrimitiveType.Int(Validation.Numeric.Range(Some(1), Some(10)))

Error Handling with SchemaError

When validation fails, ZIO Blocks returns a SchemaError that provides detailed information about what went wrong and where.

SchemaError Structure

SchemaError is an exception that contains one or more error details:

import zio.blocks.schema.SchemaError

// SchemaError wraps a non-empty list of Single errors
final case class SchemaError(errors: ::[SchemaError.Single]) extends Exception

// Single error types
SchemaError.Single
 ├── ConversionFailed(source, details, cause)  // transformation/validation failure
 ├── MissingField(source, fieldName)           // required field not present
 ├── DuplicatedField(source, fieldName)        // field appears multiple times
 ├── ExpectationMismatch(source, expectation)  // type mismatch
 ├── UnknownCase(source, caseName)             // unknown variant case
 └── Message(source, details)                  // generic message

Creating Validation Errors

Use the factory methods on SchemaError to create errors:

import zio.blocks.schema.SchemaError

// For validation failures in transform
val error = SchemaError.validationFailed("must be positive")

// Generic message error
val msgError = SchemaError("Invalid input")

// With path information
import zio.blocks.schema.DynamicOptic
val pathError = SchemaError.message("Value out of range", DynamicOptic.root.field("age"))

// Conversion failure with details
val convError = SchemaError.conversionFailed(Nil, "Expected ISO date format")

Error Messages and Paths

SchemaError includes path information showing where in the data structure the error occurred:

import zio.blocks.schema.SchemaError

val error: SchemaError = ???

// Get the full error message
val msg: String = error.message

// Add path context to errors
val atField = error.atField("name")    // prepend .name to path
val atIndex = error.atIndex(0)         // prepend [0] to path
val atCase = error.atCase("Some")      // prepend case context

// Combine multiple errors
val combined = error1 ++ error2

Example error message:

Validation failed: value must be positive at: $.user.age

Integration with Wrapper Types

The most common way to use validation in ZIO Blocks is through transform, which creates a schema for a wrapper type with validation logic.

Basic Wrapper with Validation

import zio.blocks.schema.{Schema, SchemaError}

case class PositiveInt private (value: Int)

object PositiveInt {
  def unsafeMake(n: Int): PositiveInt =
    if (n > 0) PositiveInt(n)
    else throw SchemaError.validationFailed("must be positive")

  implicit val schema: Schema[PositiveInt] =
    Schema[Int].transform(unsafeMake, _.value)
}

Email Type with Regex Validation

import zio.blocks.schema.{Schema, SchemaError}

case class Email private (value: String)

object Email {
  private val EmailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$".r

  def unsafeMake(s: String): Email =
    s match {
      case EmailRegex(_*) => Email(s)
      case _ => throw SchemaError.validationFailed("Invalid email format")
    }

  implicit val schema: Schema[Email] =
    Schema[String].transform(unsafeMake, _.value).withTypeName[Email]
}

NonEmptyString with Length Validation

import zio.blocks.schema.{Schema, SchemaError}

case class NonEmptyString private (value: String)

object NonEmptyString {
  def unsafeMake(s: String): NonEmptyString =
    if (s.nonEmpty) NonEmptyString(s)
    else throw SchemaError.validationFailed("String must not be empty")

  implicit val schema: Schema[NonEmptyString] =
    Schema[String].transform(unsafeMake, _.value).withTypeName[NonEmptyString]
}

Range-Bounded Integer

import zio.blocks.schema.{Schema, SchemaError}

case class Percentage private (value: Int)

object Percentage {
  def unsafeMake(n: Int): Percentage =
    if (n >= 0 && n <= 100) Percentage(n)
    else throw SchemaError.validationFailed(s"Percentage must be 0-100, got $n")

  implicit val schema: Schema[Percentage] =
    Schema[Int].transform(unsafeMake, _.value).withTypeName[Percentage]
}

Bidirectional Validation

Use the two-argument transform for cases where both encoding and decoding need validation:

import zio.blocks.schema.{Schema, SchemaError}

case class BoundedValue(value: Int)

object BoundedValue {
  implicit val schema: Schema[BoundedValue] = Schema[Int].transform(
    wrap = n =>
      if (n >= 0 && n < 100) BoundedValue(n)
      else throw SchemaError.validationFailed("Value must be in [0, 100)"),
    unwrap = v =>
      if (v.value >= 0) v.value
      else throw SchemaError.validationFailed("Corrupted value")
  )
}

Validation at Encode/Decode Time

Decoding with Validation

When decoding from DynamicValue or JSON, validations in wrapper schemas are automatically enforced:

import zio.blocks.schema._
import zio.blocks.schema.json._

case class PositiveInt(value: Int)
object PositiveInt {
  def unsafeMake(n: Int): PositiveInt =
    if (n > 0) PositiveInt(n)
    else throw SchemaError.validationFailed("must be positive")

  implicit val schema: Schema[PositiveInt] =
    Schema[Int].transform(unsafeMake, _.value)
}

case class Order(quantity: PositiveInt, price: BigDecimal)
object Order {
  implicit val schema: Schema[Order] = Schema.derived
}

// JSON decoding will validate PositiveInt
val json = """{"quantity": -5, "price": 99.99}"""
val result = JsonDecoder[Order].decodeString(json)
// result: Left(SchemaError: must be positive at $.quantity)

DynamicValue Validation

Use DynamicSchema to validate DynamicValue instances:

import zio.blocks.schema._

case class Person(name: String, age: Int)
object Person {
  implicit val schema: Schema[Person] = Schema.derived
}

// Create a DynamicSchema for validation
val dynamicSchema: DynamicSchema = Schema[Person].toDynamicSchema

// Create a DynamicValue to validate
val value = DynamicValue.Record(Vector(
  "name" -> DynamicValue.Primitive(PrimitiveValue.String("Alice")),
  "age" -> DynamicValue.Primitive(PrimitiveValue.Int(30))
))

// Validate the value
val checkResult: Option[SchemaError] = dynamicSchema.check(value)
// None if valid, Some(error) if invalid

val isValid: Boolean = dynamicSchema.conforms(value)
// true if valid

Converting DynamicSchema to Validating Schema

DynamicSchema.toSchema creates a Schema[DynamicValue] that rejects non-conforming values:

import zio.blocks.schema._

val dynamicSchema: DynamicSchema = Schema[Person].toDynamicSchema
val validatingSchema: Schema[DynamicValue] = dynamicSchema.toSchema

// Now any decoding through this schema will validate structure
val invalidValue = DynamicValue.Record(Vector(
  "name" -> DynamicValue.Primitive(PrimitiveValue.Int(42))  // wrong type!
))

val result = validatingSchema.fromDynamicValue(invalidValue)
// Left(SchemaError: Expected String, got Int at $.name)

Validation in JSON Schema

When deriving JSON Schema from a ZIO Blocks schema, validations are reflected in the output:

import zio.blocks.schema._
import zio.blocks.schema.json.JsonSchema

// Numeric validations become JSON Schema constraints
// Validation.Numeric.Range(Some(0), Some(100)) → "minimum": 0, "maximum": 100

// String validations become JSON Schema constraints
// Validation.String.NonEmpty → "minLength": 1
// Validation.String.Length(Some(1), Some(255)) → "minLength": 1, "maxLength": 255
// Validation.String.Pattern("^[a-z]+$") → "pattern": "^[a-z]+$"

When parsing JSON Schema, these constraints are converted back to Validation instances.

Composing Validations

The current Validation ADT does not support combining multiple validations on a single primitive (e.g., both NonEmpty and Pattern). For complex validation logic, use transform:

import zio.blocks.schema.{Schema, SchemaError}

case class Username private (value: String)

object Username {
  private val UsernameRegex = "^[a-z][a-z0-9_]{2,19}$".r

  def unsafeMake(s: String): Username = {
    if (s.isEmpty)
      throw SchemaError.validationFailed("Username cannot be empty")
    else if (s.length < 3)
      throw SchemaError.validationFailed("Username must be at least 3 characters")
    else if (s.length > 20)
      throw SchemaError.validationFailed("Username cannot exceed 20 characters")
    else if (!s.matches(UsernameRegex.regex))
      throw SchemaError.validationFailed("Username must start with a letter and contain only lowercase letters, numbers, and underscores")
    else
      Username(s)
  }

  implicit val schema: Schema[Username] =
    Schema[String].transform(unsafeMake, _.value).withTypeName[Username]
}

Best Practices

1. Use Wrapper Types for Domain Validation

Prefer creating dedicated wrapper types with transform over relying solely on Validation constraints:

// Good: Explicit domain type with validation
case class OrderId private (value: String)
object OrderId {
  def unsafeMake(s: String): OrderId =
    if (s.matches("^ORD-\\d{8}$")) OrderId(s)
    else throw SchemaError.validationFailed("Invalid order ID format")

  implicit val schema: Schema[OrderId] =
    Schema[String].transform(unsafeMake, _.value).withTypeName[OrderId]
}

// Less ideal: Raw String with separate validation
val orderIdString: String = ???

2. Provide Clear Error Messages

Include context in error messages to help users understand what went wrong:

// Good: Specific, actionable error message
throw SchemaError.validationFailed(
  s"Age must be between 0 and 150, got $age"
)

// Less helpful: Generic message
throw SchemaError.validationFailed("Invalid age")

3. Combine Errors When Possible

For multiple validation failures, combine them into a single SchemaError:

def validate(input: Input): Either[SchemaError, ValidInput] = {
  val errors = List.newBuilder[SchemaError]

  if (input.name.isEmpty)
    errors += SchemaError.validationFailed("name is required")
  if (input.age < 0)
    errors += SchemaError.validationFailed("age must be non-negative")

  val allErrors = errors.result()
  if (allErrors.isEmpty) Right(ValidInput(input))
  else Left(allErrors.reduce(_ ++ _))
}