@zio.dev/zio-blocks 0.0.25 → 0.0.27

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,569 @@
1
+ ---
2
+ id: schema-error
3
+ title: "SchemaError"
4
+ ---
5
+
6
+ `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`](./dynamic-optic.md) path that pinpoints the failing location in the data structure.
7
+
8
+ ```scala
9
+ final case class SchemaError(errors: ::[SchemaError.Single])
10
+ extends Exception with NoStackTrace
11
+ ```
12
+
13
+ Here is the full structure of `SchemaError`:
14
+
15
+ ```
16
+ SchemaError
17
+ └── errors: ::[Single] (non-empty list — always at least one failure)
18
+ │
19
+ └── Single (sealed trait)
20
+ │ ├── source: DynamicOptic (path to the failing location)
21
+ │ └── message: String (human-readable description)
22
+ │
23
+ ├── ConversionFailed (type or value conversion failed)
24
+ ├── MissingField (required field absent)
25
+ ├── DuplicatedField (same field key appears more than once)
26
+ ├── ExpectationMismatch (wrong DynamicValue variant encountered)
27
+ ├── UnknownCase (unrecognised sealed-trait discriminator)
28
+ └── Message (free-form message, optional path)
29
+ ```
30
+
31
+
32
+ `SchemaError`:
33
+
34
+ - Aggregates multiple independent failures into a single error value
35
+ - Annotates every failure with a precise traversal path through the data
36
+ - Extends `Exception` so it can be thrown and caught with standard JVM mechanisms
37
+ - Suppresses stack traces via `NoStackTrace` — error location is conveyed through the path, not the JVM stack
38
+
39
+ ## Motivation
40
+
41
+ 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.
42
+
43
+ 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.
44
+
45
+ `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.
46
+
47
+ Here is a quick taste of how `SchemaError` behaves:
48
+
49
+ ```scala
50
+ import zio.blocks.schema.SchemaError
51
+
52
+ // Create a simple message error
53
+ val err = SchemaError("Age must be positive")
54
+
55
+ // Annotate with the location in the data
56
+ val located = SchemaError.missingField(Nil, "email").atField("user")
57
+ println(located.message) // Missing field 'email' at: .user
58
+
59
+ // Combine two independent failures
60
+ val combined = SchemaError("name is blank") ++ SchemaError("age is negative")
61
+ println(combined.errors.length) // 2
62
+ ```
63
+
64
+ ## Construction / Creating Instances
65
+
66
+ ### `SchemaError.apply`
67
+
68
+ The simplest constructor — creates a free-form `Message` error at the root path:
69
+
70
+ ```scala
71
+ object SchemaError {
72
+ def apply(details: String): SchemaError
73
+ }
74
+ ```
75
+
76
+ ```scala
77
+ import zio.blocks.schema.SchemaError
78
+
79
+ val err = SchemaError("Value must be positive")
80
+ println(err.message) // Value must be positive
81
+ ```
82
+
83
+ ### `SchemaError.message`
84
+
85
+ Creates a `Message` error with a free-form description and an optional `DynamicOptic` path. When no path is supplied it defaults to the root.
86
+
87
+ ```scala
88
+ object SchemaError {
89
+ def message(details: String, path: DynamicOptic = DynamicOptic.root): SchemaError
90
+ }
91
+ ```
92
+
93
+ Here we create a message error at the root, and another with an explicit path:
94
+
95
+ ```scala
96
+ import zio.blocks.schema.{DynamicOptic, SchemaError}
97
+
98
+ // Root-level message (same as SchemaError.apply)
99
+ val atRoot = SchemaError.message("Unexpected null")
100
+ println(atRoot.message) // Unexpected null
101
+
102
+ // Message with an explicit path
103
+ val path = DynamicOptic.root.field("address")
104
+ val atPath = SchemaError.message("Unexpected null", path)
105
+ println(atPath.message) // Unexpected null at: .address
106
+ ```
107
+
108
+ ### `SchemaError.validationFailed`
109
+
110
+ Convenience factory for validation failures. Equivalent to `SchemaError.conversionFailed(Nil, message)`, designed for smart constructors that return string-based error messages.
111
+
112
+ ```scala
113
+ object SchemaError {
114
+ def validationFailed(message: String): SchemaError
115
+ }
116
+ ```
117
+
118
+ Here is an example:
119
+
120
+ ```scala
121
+ import zio.blocks.schema.SchemaError
122
+
123
+ val err = SchemaError.validationFailed("Age must be between 0 and 150")
124
+ println(err.message) // Age must be between 0 and 150
125
+ ```
126
+
127
+ ### `SchemaError.conversionFailed`
128
+
129
+ Creates a `ConversionFailed` error for a failed type or value conversion. Two overloads exist.
130
+
131
+ ```scala
132
+ object SchemaError {
133
+ def conversionFailed(trace: List[DynamicOptic.Node], details: String): SchemaError
134
+ def conversionFailed(contextMessage: String, cause: SchemaError): SchemaError
135
+ }
136
+ ```
137
+
138
+ 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.
139
+
140
+ ```scala
141
+ import zio.blocks.schema.SchemaError
142
+
143
+ // Root-level conversion failure
144
+ val err = SchemaError.conversionFailed(Nil, "Expected a positive integer")
145
+ println(err.message) // Expected a positive integer
146
+
147
+ // Wrapping a nested failure with context
148
+ val inner = SchemaError("name must not be empty") ++
149
+ SchemaError("age must be positive")
150
+ val outer = SchemaError.conversionFailed("Person construction failed", inner)
151
+ println(outer.message)
152
+ // Person construction failed
153
+ // Caused by:
154
+ // - name must not be empty
155
+ // - age must be positive
156
+ ```
157
+
158
+ ### `SchemaError.missingField`
159
+
160
+ Creates a `MissingField` error indicating that a required field was absent from the decoded representation.
161
+
162
+ ```scala
163
+ object SchemaError {
164
+ def missingField(trace: List[DynamicOptic.Node], fieldName: String): SchemaError
165
+ }
166
+ ```
167
+
168
+ ```scala
169
+ import zio.blocks.schema.SchemaError
170
+
171
+ val err = SchemaError.missingField(Nil, "email").atField("user")
172
+ println(err.message) // Missing field 'email' at: .user
173
+ ```
174
+
175
+ ### `SchemaError.duplicatedField`
176
+
177
+ Creates a `DuplicatedField` error indicating that the same field key appeared more than once in the encoded form.
178
+
179
+ ```scala
180
+ object SchemaError {
181
+ def duplicatedField(trace: List[DynamicOptic.Node], fieldName: String): SchemaError
182
+ }
183
+ ```
184
+
185
+ ```scala
186
+ import zio.blocks.schema.SchemaError
187
+
188
+ val err = SchemaError.duplicatedField(Nil, "id").atField("record")
189
+ println(err.message) // Duplicated field 'id' at: .record
190
+ ```
191
+
192
+ ### `SchemaError.expectationMismatch`
193
+
194
+ Creates an `ExpectationMismatch` error indicating that the encountered `DynamicValue` variant does not match what the schema expected.
195
+
196
+ ```scala
197
+ object SchemaError {
198
+ def expectationMismatch(trace: List[DynamicOptic.Node], expectation: String): SchemaError
199
+ }
200
+ ```
201
+
202
+ ```scala
203
+ import zio.blocks.schema.SchemaError
204
+
205
+ val err = SchemaError
206
+ .expectationMismatch(Nil, "Expected Record, got Sequence")
207
+ .atField("data")
208
+ println(err.message) // Expected Record, got Sequence at: .data
209
+ ```
210
+
211
+ ### `SchemaError.unknownCase`
212
+
213
+ Creates an `UnknownCase` error indicating that the decoded discriminator value does not correspond to any known variant of a sealed trait.
214
+
215
+ ```scala
216
+ object SchemaError {
217
+ def unknownCase(trace: List[DynamicOptic.Node], caseName: String): SchemaError
218
+ }
219
+ ```
220
+
221
+ ```scala
222
+ import zio.blocks.schema.SchemaError
223
+
224
+ val err = SchemaError.unknownCase(Nil, "Triangle").atField("shape")
225
+ println(err.message) // Unknown case 'Triangle' at: .shape
226
+ ```
227
+
228
+ ## Core Operations
229
+
230
+ ### Error Messages
231
+
232
+ #### `message`
233
+
234
+ Returns all individual error messages joined with newlines.
235
+
236
+ ```scala
237
+ final case class SchemaError(errors: ::[SchemaError.Single]) {
238
+ def message: String
239
+ }
240
+ ```
241
+
242
+ ```scala
243
+ import zio.blocks.schema.SchemaError
244
+
245
+ val err = SchemaError("first failure") ++ SchemaError("second failure")
246
+ println(err.message)
247
+ // first failure
248
+ // second failure
249
+ ```
250
+
251
+ #### `getMessage`
252
+
253
+ Delegates to `message`. Because `SchemaError` extends `Exception`, `getMessage` is called by the JVM when the exception is printed or logged by frameworks.
254
+
255
+ ```scala
256
+ final case class SchemaError(errors: ::[SchemaError.Single]) {
257
+ def getMessage: String
258
+ }
259
+ ```
260
+
261
+ ```scala
262
+ import zio.blocks.schema.SchemaError
263
+
264
+ val err = SchemaError("something went wrong")
265
+ assert(err.getMessage == err.message)
266
+ ```
267
+
268
+ ### Error Aggregation
269
+
270
+ #### `++`
271
+
272
+ 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.
273
+
274
+ ```scala
275
+ final case class SchemaError(errors: ::[SchemaError.Single]) {
276
+ def ++(other: SchemaError): SchemaError
277
+ }
278
+ ```
279
+
280
+ ```scala
281
+ import zio.blocks.schema.SchemaError
282
+
283
+ val nameError = SchemaError.missingField(Nil, "name")
284
+ val ageError = SchemaError.conversionFailed(Nil, "Age must be positive")
285
+ val combined = nameError ++ ageError
286
+
287
+ println(combined.errors.length) // 2
288
+ println(combined.message)
289
+ // Missing field 'name' at: .
290
+ // Age must be positive
291
+ ```
292
+
293
+ `++` is associative: `(a ++ b) ++ c` and `a ++ (b ++ c)` produce the same set of errors.
294
+
295
+ ### Path Annotation
296
+
297
+ 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.
298
+
299
+ #### `atField`
300
+
301
+ Prepends a record field access to the path of all errors.
302
+
303
+ ```scala
304
+ final case class SchemaError(errors: ::[SchemaError.Single]) {
305
+ def atField(name: String): SchemaError
306
+ }
307
+ ```
308
+
309
+ ```scala
310
+ import zio.blocks.schema.SchemaError
311
+
312
+ // Codec decoding 'city' inside 'address' inside 'user'
313
+ val err = SchemaError.missingField(Nil, "city")
314
+ .atField("address") // called by the address codec
315
+ .atField("user") // called by the user codec
316
+ println(err.message) // Missing field 'city' at: .user.address
317
+ ```
318
+
319
+ #### `atIndex`
320
+
321
+ Prepends a sequence index access to the path of all errors.
322
+
323
+ ```scala
324
+ final case class SchemaError(errors: ::[SchemaError.Single]) {
325
+ def atIndex(index: Int): SchemaError
326
+ }
327
+ ```
328
+
329
+ ```scala
330
+ import zio.blocks.schema.SchemaError
331
+
332
+ val err = SchemaError("invalid phone number").atIndex(2).atField("phones")
333
+ println(err.message) // invalid phone number at: .phones[2]
334
+ ```
335
+
336
+ #### `atCase`
337
+
338
+ 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>`.
339
+
340
+ ```scala
341
+ final case class SchemaError(errors: ::[SchemaError.Single]) {
342
+ def atCase(name: String): SchemaError
343
+ }
344
+ ```
345
+
346
+ ```scala
347
+ import zio.blocks.schema.SchemaError
348
+
349
+ val err = SchemaError("conversion failed").atField("value").atCase("Right")
350
+ println(err.message) // conversion failed at: <Right>.value
351
+ ```
352
+
353
+ #### `atKey`
354
+
355
+ Prepends a map key access to the path of all errors. The key is a [`DynamicValue`](./dynamic-value.md), rendered with `{key}` in the compact path notation.
356
+
357
+ ```scala
358
+ final case class SchemaError(errors: ::[SchemaError.Single]) {
359
+ def atKey(key: DynamicValue): SchemaError
360
+ }
361
+ ```
362
+
363
+ ```scala
364
+ import zio.blocks.schema.{DynamicValue, SchemaError}
365
+
366
+ val key = DynamicValue.string("config")
367
+ val err = SchemaError("missing required entry").atKey(key)
368
+ println(err.message) // missing required entry at: {"config"}
369
+ ```
370
+
371
+ ### Path Chaining
372
+
373
+ 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.
374
+
375
+ ```scala
376
+ import zio.blocks.schema.SchemaError
377
+
378
+ val err = SchemaError("value out of range")
379
+ .atField("amount") // innermost — added first
380
+ .atIndex(0)
381
+ .atCase("Credit")
382
+ .atField("transactions") // outermost — added last
383
+ println(err.message)
384
+ // value out of range at: .transactions<Credit>[0].amount
385
+ ```
386
+
387
+ Path annotation applies to **every** `Single` inside the error, so combined errors accumulate paths correctly:
388
+
389
+ ```scala
390
+ import zio.blocks.schema.SchemaError
391
+
392
+ val error1 = SchemaError.missingField(Nil, "name")
393
+ val error2 = SchemaError.conversionFailed(Nil, "age must be positive")
394
+ val combined = (error1 ++ error2).atField("person")
395
+
396
+ // Both errors now include the "person" field prefix
397
+ println(combined.errors.head.source.nodes.nonEmpty) // true (name)
398
+ println(combined.errors.tail.head.source.nodes.nonEmpty) // true (age)
399
+ ```
400
+
401
+ ## Subtypes / Variants
402
+
403
+ `SchemaError.Single` is the sealed base trait for every individual failure. Each variant carries a `source: DynamicOptic` and a `message: String`.
404
+
405
+ ```
406
+ SchemaError.Single (sealed trait)
407
+ ├── SchemaError.IntoError (sealed sub-trait — marks conversion errors)
408
+ │ └── ConversionFailed(source, details, cause: Option[SchemaError])
409
+ ├── MissingField(source, fieldName)
410
+ ├── DuplicatedField(source, fieldName)
411
+ ├── ExpectationMismatch(source, expectation)
412
+ ├── UnknownCase(source, caseName)
413
+ └── Message(source, details)
414
+ ```
415
+
416
+ | Subtype | Factory | Typical cause |
417
+ |-----------------------|----------------------------------------|-------------------------------------------------------------------------------------------|
418
+ | `ConversionFailed` | `conversionFailed`, `validationFailed` | Type conversion or smart-constructor failure; may carry a nested `SchemaError` as `cause` |
419
+ | `MissingField` | `missingField` | Required field absent in the decoded representation |
420
+ | `DuplicatedField` | `duplicatedField` | Same field key appears more than once |
421
+ | `ExpectationMismatch` | `expectationMismatch` | Wrong `DynamicValue` variant encountered |
422
+ | `UnknownCase` | `unknownCase` | Discriminator names an unrecognised sealed-trait variant |
423
+ | `Message` | `message`, `apply` | Free-form error with an optional path |
424
+
425
+ We can pattern match on `errors` to handle specific failure kinds:
426
+
427
+ ```scala
428
+ import zio.blocks.schema.SchemaError
429
+
430
+ val err = SchemaError.missingField(Nil, "email") ++
431
+ SchemaError.conversionFailed(Nil, "age must be positive")
432
+
433
+ err.errors.foreach {
434
+ case SchemaError.MissingField(source, fieldName) =>
435
+ println(s"Missing '$fieldName' at ${source.toScalaString}")
436
+ case SchemaError.ConversionFailed(source, details, _) =>
437
+ println(s"Conversion failed: $details")
438
+ case other =>
439
+ println(other.message)
440
+ }
441
+ ```
442
+
443
+ ### `SchemaError.IntoError`
444
+
445
+ `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:
446
+
447
+ ```scala
448
+ sealed trait IntoError extends SchemaError.Single {
449
+ def source: DynamicOptic
450
+ }
451
+ ```
452
+
453
+ ### `SchemaError.ConversionFailed`
454
+
455
+ 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.
456
+
457
+ ```scala
458
+ import zio.blocks.schema.SchemaError
459
+
460
+ // Single nested cause
461
+ val inner1 = SchemaError.conversionFailed(Nil, "name is blank")
462
+ val outer1 = SchemaError.conversionFailed("Person construction failed", inner1)
463
+ println(outer1.message)
464
+ // Person construction failed
465
+ // Caused by: name is blank
466
+
467
+ // Multiple nested causes
468
+ val inner2 = SchemaError.conversionFailed(Nil, "name is blank") ++
469
+ SchemaError.conversionFailed(Nil, "age is negative")
470
+ val outer2 = SchemaError.conversionFailed("Person construction failed", inner2)
471
+ println(outer2.message)
472
+ // Person construction failed
473
+ // Caused by:
474
+ // - name is blank
475
+ // - age is negative
476
+ ```
477
+
478
+ ## Integration
479
+
480
+ ### With Schema Decoding
481
+
482
+ `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.
483
+
484
+ ```scala
485
+ import zio.blocks.schema.{Schema, SchemaError}
486
+
487
+ case class Person(name: String, age: Int)
488
+
489
+ object Person {
490
+ implicit val schema: Schema[Person] = Schema.derived
491
+ }
492
+
493
+ val result: Either[SchemaError, Person] =
494
+ Schema[Person].fromDynamicValue(Schema[Person].toDynamicValue(Person("Alice", 30)))
495
+
496
+ result match {
497
+ case Right(person) => println(s"Decoded: $person")
498
+ case Left(err) => println(s"Error:\n${err.message}")
499
+ }
500
+ ```
501
+
502
+ See [Schema](./schema.md) and [DynamicValue](./dynamic-value.md) for the full encoding and decoding API.
503
+
504
+ ### With Schema#transform
505
+
506
+ `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:
507
+
508
+ ```scala
509
+ import zio.blocks.schema.{Schema, SchemaError}
510
+
511
+ case class PositiveInt private (value: Int)
512
+
513
+ object PositiveInt {
514
+ def make(n: Int): PositiveInt =
515
+ if (n > 0) PositiveInt(n)
516
+ else throw SchemaError.validationFailed("must be positive")
517
+
518
+ implicit val schema: Schema[PositiveInt] =
519
+ Schema[Int].transform(make, _.value)
520
+ }
521
+ ```
522
+
523
+ 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](./schema.md) for the full `transform` API.
524
+
525
+ A runnable version of this example, including composite types and error aggregation, is available in the `schema-examples` module:
526
+
527
+ ```bash
528
+ sbt "schema-examples/runMain schemaerror.SchemaErrorExample"
529
+ ```
530
+
531
+ ### With Validation
532
+
533
+ The [Validation](./validation.md) 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.
534
+
535
+ ### With DynamicOptic and Optics
536
+
537
+ Path annotation (`atField`, `atIndex`, `atKey`, `atCase`) builds a [`DynamicOptic`](./dynamic-optic.md) inside each error. Operations such as `DynamicValue#setOrFail` and `DynamicValue#modifyAtPathOrFail` return `Either[SchemaError, DynamicValue]`, using the same factory methods.
538
+
539
+ ```scala
540
+ import zio.blocks.schema.{DynamicOptic, DynamicValue, Schema, SchemaError}
541
+
542
+ implicit val intSchema: Schema[Int] = Schema[Int]
543
+ val data = DynamicValue.Sequence(DynamicValue.int(1), DynamicValue.int(2))
544
+ val optic = DynamicOptic.root.at(10)
545
+
546
+ val result: Either[SchemaError, DynamicValue] = data.setOrFail(optic, DynamicValue.int(99))
547
+ result match {
548
+ case Left(err) => println(err.message) // index out of range or similar
549
+ case Right(v) => println(v)
550
+ }
551
+ ```
552
+
553
+ ### As an Exception
554
+
555
+ 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):
556
+
557
+ ```scala
558
+ import zio.blocks.schema.SchemaError
559
+
560
+ try {
561
+ throw SchemaError("Unexpected data shape")
562
+ } catch {
563
+ case e: SchemaError => println(s"Caught schema error: ${e.getMessage}")
564
+ }
565
+ ```
566
+
567
+ :::note
568
+ `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.
569
+ :::
@@ -70,13 +70,13 @@ val result: Either[OpticCheck, Seq[Boolean]] = combined.eval(alice)
70
70
  ## Installation
71
71
 
72
72
  ```scala
73
- libraryDependencies += "dev.zio" %% "zio-blocks-schema" % "0.0.25"
73
+ libraryDependencies += "dev.zio" %% "zio-blocks-schema" % "0.0.27"
74
74
  ```
75
75
 
76
76
  For cross-platform (Scala.js):
77
77
 
78
78
  ```scala
79
- libraryDependencies += "dev.zio" %%% "zio-blocks-schema" % "0.0.25"
79
+ libraryDependencies += "dev.zio" %%% "zio-blocks-schema" % "0.0.27"
80
80
  ```
81
81
 
82
82
  Supported Scala versions: 2.13.x and 3.x.
@@ -618,3 +618,32 @@ object UserId {
618
618
  .transform(UserId(_), _.value)
619
619
  }
620
620
  ```
621
+
622
+ ## Compile-Time Shape Constraints (`Allows`)
623
+
624
+ ZIO Blocks provides `Allows[A, S]` — a phantom-typed capability token that proves, at compile time, that type `A` satisfies the structural grammar `S`. This lets library authors express and enforce structural preconditions on their generic APIs without writing macros themselves.
625
+
626
+ ```scala
627
+ import zio.blocks.schema.Schema
628
+ import zio.blocks.schema.comptime.Allows
629
+ import Allows._
630
+
631
+ // Require a flat record of scalars (e.g. for CSV or RDBMS)
632
+ def writeCsv[A: Schema](rows: Seq[A])(using
633
+ Allows[A, Record[Primitive | Optional[Primitive]]]
634
+ ): Unit = ???
635
+
636
+ // Sealed traits auto-unwrap: each case must satisfy Record[...] — no Variant node needed
637
+ def publish[A: Schema](event: A)(using
638
+ Allows[A, Record[Primitive | Sequence[Primitive]]]
639
+ ): Unit = ???
640
+
641
+ // Recursive grammar (e.g. for a JSON document store)
642
+ def toJson[A: Schema](doc: A)(using
643
+ Allows[A, Record[Primitive | Self | Optional[Primitive | Self] | Sequence[Primitive | Self]]]
644
+ ): String = ???
645
+ ```
646
+
647
+ When a type does not satisfy the grammar, the user gets a precise compile-time error naming the violating field and suggesting a fix. No runtime surprises.
648
+
649
+ See the [`Allows` reference](./allows.md) for the full grammar node table, union syntax, `Self` for recursive types, newtypes, and error message examples.