@zio.dev/zio-blocks 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,458 @@
1
+ ---
2
+ id: validation
3
+ title: "Validation"
4
+ ---
5
+
6
+ `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`.
7
+
8
+ ## Overview
9
+
10
+ The validation system in ZIO Blocks provides:
11
+
12
+ - **Declarative constraints** on numeric and string values
13
+ - **Automatic enforcement** during schema-based decoding
14
+ - **Integration with wrapper types** via `transform` for custom validation logic
15
+ - **Schema error reporting** with path information for debugging
16
+
17
+ ```
18
+ Validation[A]
19
+ ├── Validation.None (no constraint)
20
+
21
+ ├── Validation.Numeric[A] (numeric constraints)
22
+ │ ├── Positive (> 0)
23
+ │ ├── Negative (< 0)
24
+ │ ├── NonPositive (<= 0)
25
+ │ ├── NonNegative (>= 0)
26
+ │ ├── Range[A](min, max) (within bounds)
27
+ │ └── Set[A](values) (one of specific values)
28
+
29
+ └── Validation.String (string constraints)
30
+ ├── NonEmpty (length > 0)
31
+ ├── Empty (length == 0)
32
+ ├── Blank (whitespace only)
33
+ ├── NonBlank (has non-whitespace)
34
+ ├── Length(min, max) (length bounds)
35
+ └── Pattern(regex) (regex match)
36
+ ```
37
+
38
+ ## Built-in Validations
39
+
40
+ ### Numeric Validations
41
+
42
+ Numeric validations apply to `Byte`, `Short`, `Int`, `Long`, `Float`, `Double`, `BigInt`, and `BigDecimal`.
43
+
44
+ ```scala
45
+ import zio.blocks.schema.Validation
46
+
47
+ // Sign constraints
48
+ Validation.Numeric.Positive // value > 0
49
+ Validation.Numeric.Negative // value < 0
50
+ Validation.Numeric.NonPositive // value <= 0
51
+ Validation.Numeric.NonNegative // value >= 0
52
+
53
+ // Range constraint (inclusive bounds)
54
+ Validation.Numeric.Range(Some(1), Some(100)) // 1 <= value <= 100
55
+ Validation.Numeric.Range(Some(0), None) // value >= 0 (no upper bound)
56
+ Validation.Numeric.Range(None, Some(1000)) // value <= 1000 (no lower bound)
57
+
58
+ // Set constraint (value must be one of the specified values)
59
+ Validation.Numeric.Set(Set(1, 2, 3, 5, 8, 13))
60
+ ```
61
+
62
+ ### String Validations
63
+
64
+ String validations apply to `String` primitive types.
65
+
66
+ ```scala
67
+ import zio.blocks.schema.Validation
68
+
69
+ // Content constraints
70
+ Validation.String.NonEmpty // string.nonEmpty (length > 0)
71
+ Validation.String.Empty // string.isEmpty (length == 0)
72
+ Validation.String.Blank // string.trim.isEmpty (whitespace only)
73
+ Validation.String.NonBlank // string.trim.nonEmpty (has non-whitespace)
74
+
75
+ // Length constraint (inclusive bounds)
76
+ Validation.String.Length(Some(1), Some(255)) // 1 <= length <= 255
77
+ Validation.String.Length(Some(3), None) // length >= 3
78
+ Validation.String.Length(None, Some(100)) // length <= 100
79
+
80
+ // Pattern constraint (regex)
81
+ Validation.String.Pattern("^[a-z]+$") // lowercase letters only
82
+ Validation.String.Pattern("^\\d{5}$") // exactly 5 digits
83
+ ```
84
+
85
+ ## Validation with PrimitiveType
86
+
87
+ Validations are attached to `PrimitiveType` instances. Each primitive type carries its validation constraint:
88
+
89
+ ```scala
90
+ import zio.blocks.schema.{PrimitiveType, Validation}
91
+
92
+ // Int with no validation
93
+ val intType = PrimitiveType.Int(Validation.None)
94
+
95
+ // Positive Int
96
+ val positiveIntType = PrimitiveType.Int(Validation.Numeric.Positive)
97
+
98
+ // Non-empty String
99
+ val nonEmptyStringType = PrimitiveType.String(Validation.String.NonEmpty)
100
+
101
+ // String matching email pattern
102
+ val emailPattern = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$"
103
+ val emailStringType = PrimitiveType.String(Validation.String.Pattern(emailPattern))
104
+
105
+ // Int in range 1-10
106
+ val rangeIntType = PrimitiveType.Int(Validation.Numeric.Range(Some(1), Some(10)))
107
+ ```
108
+
109
+ ## Error Handling with SchemaError
110
+
111
+ When validation fails, ZIO Blocks returns a `SchemaError` that provides detailed information about what went wrong and where.
112
+
113
+ ### SchemaError Structure
114
+
115
+ `SchemaError` is an exception that contains one or more error details:
116
+
117
+ ```scala
118
+ import zio.blocks.schema.SchemaError
119
+
120
+ // SchemaError wraps a non-empty list of Single errors
121
+ final case class SchemaError(errors: ::[SchemaError.Single]) extends Exception
122
+
123
+ // Single error types
124
+ SchemaError.Single
125
+ ├── ConversionFailed(source, details, cause) // transformation/validation failure
126
+ ├── MissingField(source, fieldName) // required field not present
127
+ ├── DuplicatedField(source, fieldName) // field appears multiple times
128
+ ├── ExpectationMismatch(source, expectation) // type mismatch
129
+ ├── UnknownCase(source, caseName) // unknown variant case
130
+ └── Message(source, details) // generic message
131
+ ```
132
+
133
+ ### Creating Validation Errors
134
+
135
+ Use the factory methods on `SchemaError` to create errors:
136
+
137
+ ```scala
138
+ import zio.blocks.schema.SchemaError
139
+
140
+ // For validation failures in transform
141
+ val error = SchemaError.validationFailed("must be positive")
142
+
143
+ // Generic message error
144
+ val msgError = SchemaError("Invalid input")
145
+
146
+ // With path information
147
+ import zio.blocks.schema.DynamicOptic
148
+ val pathError = SchemaError.message("Value out of range", DynamicOptic.root.field("age"))
149
+
150
+ // Conversion failure with details
151
+ val convError = SchemaError.conversionFailed(Nil, "Expected ISO date format")
152
+ ```
153
+
154
+ ### Error Messages and Paths
155
+
156
+ `SchemaError` includes path information showing where in the data structure the error occurred:
157
+
158
+ ```scala
159
+ import zio.blocks.schema.SchemaError
160
+
161
+ val error: SchemaError = ???
162
+
163
+ // Get the full error message
164
+ val msg: String = error.message
165
+
166
+ // Add path context to errors
167
+ val atField = error.atField("name") // prepend .name to path
168
+ val atIndex = error.atIndex(0) // prepend [0] to path
169
+ val atCase = error.atCase("Some") // prepend case context
170
+
171
+ // Combine multiple errors
172
+ val combined = error1 ++ error2
173
+ ```
174
+
175
+ Example error message:
176
+ ```
177
+ Validation failed: value must be positive at: $.user.age
178
+ ```
179
+
180
+ ## Integration with Wrapper Types
181
+
182
+ The most common way to use validation in ZIO Blocks is through `transform`, which creates a schema for a wrapper type with validation logic.
183
+
184
+ ### Basic Wrapper with Validation
185
+
186
+ ```scala
187
+ import zio.blocks.schema.{Schema, SchemaError}
188
+
189
+ case class PositiveInt private (value: Int)
190
+
191
+ object PositiveInt {
192
+ def unsafeMake(n: Int): PositiveInt =
193
+ if (n > 0) PositiveInt(n)
194
+ else throw SchemaError.validationFailed("must be positive")
195
+
196
+ implicit val schema: Schema[PositiveInt] =
197
+ Schema[Int].transform(unsafeMake, _.value)
198
+ }
199
+ ```
200
+
201
+ ### Email Type with Regex Validation
202
+
203
+ ```scala
204
+ import zio.blocks.schema.{Schema, SchemaError}
205
+
206
+ case class Email private (value: String)
207
+
208
+ object Email {
209
+ private val EmailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$".r
210
+
211
+ def unsafeMake(s: String): Email =
212
+ s match {
213
+ case EmailRegex(_*) => Email(s)
214
+ case _ => throw SchemaError.validationFailed("Invalid email format")
215
+ }
216
+
217
+ implicit val schema: Schema[Email] =
218
+ Schema[String].transform(unsafeMake, _.value).withTypeName[Email]
219
+ }
220
+ ```
221
+
222
+ ### NonEmptyString with Length Validation
223
+
224
+ ```scala
225
+ import zio.blocks.schema.{Schema, SchemaError}
226
+
227
+ case class NonEmptyString private (value: String)
228
+
229
+ object NonEmptyString {
230
+ def unsafeMake(s: String): NonEmptyString =
231
+ if (s.nonEmpty) NonEmptyString(s)
232
+ else throw SchemaError.validationFailed("String must not be empty")
233
+
234
+ implicit val schema: Schema[NonEmptyString] =
235
+ Schema[String].transform(unsafeMake, _.value).withTypeName[NonEmptyString]
236
+ }
237
+ ```
238
+
239
+ ### Range-Bounded Integer
240
+
241
+ ```scala
242
+ import zio.blocks.schema.{Schema, SchemaError}
243
+
244
+ case class Percentage private (value: Int)
245
+
246
+ object Percentage {
247
+ def unsafeMake(n: Int): Percentage =
248
+ if (n >= 0 && n <= 100) Percentage(n)
249
+ else throw SchemaError.validationFailed(s"Percentage must be 0-100, got $n")
250
+
251
+ implicit val schema: Schema[Percentage] =
252
+ Schema[Int].transform(unsafeMake, _.value).withTypeName[Percentage]
253
+ }
254
+ ```
255
+
256
+ ### Bidirectional Validation
257
+
258
+ Use the two-argument `transform` for cases where both encoding and decoding need validation:
259
+
260
+ ```scala
261
+ import zio.blocks.schema.{Schema, SchemaError}
262
+
263
+ case class BoundedValue(value: Int)
264
+
265
+ object BoundedValue {
266
+ implicit val schema: Schema[BoundedValue] = Schema[Int].transform(
267
+ wrap = n =>
268
+ if (n >= 0 && n < 100) BoundedValue(n)
269
+ else throw SchemaError.validationFailed("Value must be in [0, 100)"),
270
+ unwrap = v =>
271
+ if (v.value >= 0) v.value
272
+ else throw SchemaError.validationFailed("Corrupted value")
273
+ )
274
+ }
275
+ ```
276
+
277
+ ## Validation at Encode/Decode Time
278
+
279
+ ### Decoding with Validation
280
+
281
+ When decoding from `DynamicValue` or JSON, validations in wrapper schemas are automatically enforced:
282
+
283
+ ```scala
284
+ import zio.blocks.schema._
285
+ import zio.blocks.schema.json._
286
+
287
+ case class PositiveInt(value: Int)
288
+ object PositiveInt {
289
+ def unsafeMake(n: Int): PositiveInt =
290
+ if (n > 0) PositiveInt(n)
291
+ else throw SchemaError.validationFailed("must be positive")
292
+
293
+ implicit val schema: Schema[PositiveInt] =
294
+ Schema[Int].transform(unsafeMake, _.value)
295
+ }
296
+
297
+ case class Order(quantity: PositiveInt, price: BigDecimal)
298
+ object Order {
299
+ implicit val schema: Schema[Order] = Schema.derived
300
+ }
301
+
302
+ // JSON decoding will validate PositiveInt
303
+ val json = """{"quantity": -5, "price": 99.99}"""
304
+ val result = JsonDecoder[Order].decodeString(json)
305
+ // result: Left(SchemaError: must be positive at $.quantity)
306
+ ```
307
+
308
+ ### DynamicValue Validation
309
+
310
+ Use `DynamicSchema` to validate `DynamicValue` instances:
311
+
312
+ ```scala
313
+ import zio.blocks.schema._
314
+
315
+ case class Person(name: String, age: Int)
316
+ object Person {
317
+ implicit val schema: Schema[Person] = Schema.derived
318
+ }
319
+
320
+ // Create a DynamicSchema for validation
321
+ val dynamicSchema: DynamicSchema = Schema[Person].toDynamicSchema
322
+
323
+ // Create a DynamicValue to validate
324
+ val value = DynamicValue.Record(Vector(
325
+ "name" -> DynamicValue.Primitive(PrimitiveValue.String("Alice")),
326
+ "age" -> DynamicValue.Primitive(PrimitiveValue.Int(30))
327
+ ))
328
+
329
+ // Validate the value
330
+ val checkResult: Option[SchemaError] = dynamicSchema.check(value)
331
+ // None if valid, Some(error) if invalid
332
+
333
+ val isValid: Boolean = dynamicSchema.conforms(value)
334
+ // true if valid
335
+ ```
336
+
337
+ ### Converting DynamicSchema to Validating Schema
338
+
339
+ `DynamicSchema.toSchema` creates a `Schema[DynamicValue]` that rejects non-conforming values:
340
+
341
+ ```scala
342
+ import zio.blocks.schema._
343
+
344
+ val dynamicSchema: DynamicSchema = Schema[Person].toDynamicSchema
345
+ val validatingSchema: Schema[DynamicValue] = dynamicSchema.toSchema
346
+
347
+ // Now any decoding through this schema will validate structure
348
+ val invalidValue = DynamicValue.Record(Vector(
349
+ "name" -> DynamicValue.Primitive(PrimitiveValue.Int(42)) // wrong type!
350
+ ))
351
+
352
+ val result = validatingSchema.fromDynamicValue(invalidValue)
353
+ // Left(SchemaError: Expected String, got Int at $.name)
354
+ ```
355
+
356
+ ## Validation in JSON Schema
357
+
358
+ When deriving JSON Schema from a ZIO Blocks schema, validations are reflected in the output:
359
+
360
+ ```scala
361
+ import zio.blocks.schema._
362
+ import zio.blocks.schema.json.JsonSchema
363
+
364
+ // Numeric validations become JSON Schema constraints
365
+ // Validation.Numeric.Range(Some(0), Some(100)) → "minimum": 0, "maximum": 100
366
+
367
+ // String validations become JSON Schema constraints
368
+ // Validation.String.NonEmpty → "minLength": 1
369
+ // Validation.String.Length(Some(1), Some(255)) → "minLength": 1, "maxLength": 255
370
+ // Validation.String.Pattern("^[a-z]+$") → "pattern": "^[a-z]+$"
371
+ ```
372
+
373
+ When parsing JSON Schema, these constraints are converted back to `Validation` instances.
374
+
375
+ ## Composing Validations
376
+
377
+ 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`:
378
+
379
+ ```scala
380
+ import zio.blocks.schema.{Schema, SchemaError}
381
+
382
+ case class Username private (value: String)
383
+
384
+ object Username {
385
+ private val UsernameRegex = "^[a-z][a-z0-9_]{2,19}$".r
386
+
387
+ def unsafeMake(s: String): Username = {
388
+ if (s.isEmpty)
389
+ throw SchemaError.validationFailed("Username cannot be empty")
390
+ else if (s.length < 3)
391
+ throw SchemaError.validationFailed("Username must be at least 3 characters")
392
+ else if (s.length > 20)
393
+ throw SchemaError.validationFailed("Username cannot exceed 20 characters")
394
+ else if (!s.matches(UsernameRegex.regex))
395
+ throw SchemaError.validationFailed("Username must start with a letter and contain only lowercase letters, numbers, and underscores")
396
+ else
397
+ Username(s)
398
+ }
399
+
400
+ implicit val schema: Schema[Username] =
401
+ Schema[String].transform(unsafeMake, _.value).withTypeName[Username]
402
+ }
403
+ ```
404
+
405
+ ## Best Practices
406
+
407
+ ### 1. Use Wrapper Types for Domain Validation
408
+
409
+ Prefer creating dedicated wrapper types with `transform` over relying solely on `Validation` constraints:
410
+
411
+ ```scala
412
+ // Good: Explicit domain type with validation
413
+ case class OrderId private (value: String)
414
+ object OrderId {
415
+ def unsafeMake(s: String): OrderId =
416
+ if (s.matches("^ORD-\\d{8}$")) OrderId(s)
417
+ else throw SchemaError.validationFailed("Invalid order ID format")
418
+
419
+ implicit val schema: Schema[OrderId] =
420
+ Schema[String].transform(unsafeMake, _.value).withTypeName[OrderId]
421
+ }
422
+
423
+ // Less ideal: Raw String with separate validation
424
+ val orderIdString: String = ???
425
+ ```
426
+
427
+ ### 2. Provide Clear Error Messages
428
+
429
+ Include context in error messages to help users understand what went wrong:
430
+
431
+ ```scala
432
+ // Good: Specific, actionable error message
433
+ throw SchemaError.validationFailed(
434
+ s"Age must be between 0 and 150, got $age"
435
+ )
436
+
437
+ // Less helpful: Generic message
438
+ throw SchemaError.validationFailed("Invalid age")
439
+ ```
440
+
441
+ ### 3. Combine Errors When Possible
442
+
443
+ For multiple validation failures, combine them into a single `SchemaError`:
444
+
445
+ ```scala
446
+ def validate(input: Input): Either[SchemaError, ValidInput] = {
447
+ val errors = List.newBuilder[SchemaError]
448
+
449
+ if (input.name.isEmpty)
450
+ errors += SchemaError.validationFailed("name is required")
451
+ if (input.age < 0)
452
+ errors += SchemaError.validationFailed("age must be non-negative")
453
+
454
+ val allErrors = errors.result()
455
+ if (allErrors.isEmpty) Right(ValidInput(input))
456
+ else Left(allErrors.reduce(_ ++ _))
457
+ }
458
+ ```