Skip to content

Commit bb3b420

Browse files
Custom rules (#41)
* feat: adding custom rules * refactor: added ThrowOnError to validation result
1 parent 3ffb6ff commit bb3b420

61 files changed

Lines changed: 3603 additions & 117 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎docs/wiki/Core-Concepts.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ ValidationResult<TOutput> result = schema.Validate(value);
1818
| `Validate(TInput value)` | Returns a `ValidationResult<TOutput>`. Never throws. |
1919
| `SafeParse(TInput value)` | Alias of `Validate`. |
2020
| `Parse(TInput value)` | Returns the validated `TOutput`, or throws `ZodException` on failure (via `GetValueOrThrow()`). |
21-
| `ValidateAsync(TInput value, CancellationToken)` | `ValueTask` wrapper around `Validate`. The pipeline is synchronous; this exists for interface symmetry and the source generator's custom async validation. |
21+
| `ValidateAsync(TInput value, CancellationToken)` | `ValueTask` wrapper around `Validate`. The pipeline is synchronous; the token is observed before validation and throws `OperationCanceledException` when already cancelled. Genuinely async work only happens in the source generator's custom async validation. |
2222

2323
## ValidationResult<T>
2424

@@ -64,7 +64,8 @@ catch (ZodException ex)
6464

6565
- Schemas are classes deriving from `ZodType<TOutput, TInput>`; the fluent methods return `this` (or a wrapping schema) so chains read naturally.
6666
- Rules are `readonly record struct` implementations of `IValidationRule<T>` (`bool IsValid(in T value)`, `string GetErrorMessage(in T value)`) — zero allocation.
67-
- `ValidateSpan(ReadOnlySpan<char> value)` is available on `ZodString` for span-based validation.
67+
- Custom rules can be attached with the public `AddRule`/`Rule` methods and surfaced as DataAnnotations-style attributes; see [Custom Rules](Custom-Rules.md).
68+
- `ValidateSpan(ReadOnlySpan<char> value)` is available on `ZodString` for span-based validation; use `IsValidSpan(value, out errors)` for an allocation-free check.
6869
- A schema's `Description` is set with `.Describe("...")`.
6970

7071
## Composition model

‎docs/wiki/Custom-Rules.md‎

Lines changed: 377 additions & 0 deletions
Large diffs are not rendered by default.

‎docs/wiki/Fluent-Schema-API.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,11 @@ Each schema type has its own page:
4646

4747
- `IZodSchema<TOutput, TInput>` — `Validate` / `ValidateAsync`; `IZodSchema<T>` is the convenience form where input equals output.
4848
- `IZodSchemaValidator` (marker) and `IZodSchemaValidator<T>` — the DI-facing adapter surface (see [Dependency Injection](Dependency-Injection.md)).
49-
- `IValidationRule<T>` — the rule contract implemented by every struct rule.
49+
- `IValidationRule<T>` — the rule contract implemented by every struct rule. `ZodType<TOutput, TInput>.AddRule(rule)` and `Rule<TRule>(rule)` are public, so custom rules can be attached to any schema. `ZodString` also exposes `IsValidSpan`/`ValidateSpan` for span-based string validation, and string rules that implement `IStringValidationRule` participate in the span path.
50+
- `IZodRule` — implemented by rules that own their error identity (`Code`/`Origin`). When a mapped rule implements it, the generator prefers the rule's values over the attribute's, so one attribute can produce a per-member error code.
51+
- `IStringValidationRule` — the span-based counterpart of `IValidationRule<string>`.
52+
53+
See [Custom Rules](Custom-Rules.md) for defining, attaching, and mapping rules (including generic rules).
5054

5155
## Convenience composition on any schema
5256

‎docs/wiki/Guarantees-and-Limitations.md‎

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,59 +2,63 @@
22

33
## Guarantees
44

5-
- **Zero-allocation on valid inputs.** Primitives, strings, arrays, objects, discriminated unions, and first-option unions validate without allocating when the input is valid. See [Performance](Performance.md).
6-
- **No reflection on hot paths.** The runtime library uses expression trees only in the opt-in `CompiledValidator`; the source generator emits direct typed codegen.
7-
- **Deterministic, reviewable generated code.** The `[ZodSchema]` generator output is stable and de-duplicated; no scope leaks in emitted code.
5+
- **Zero-allocation on valid inputs for the core schema types.** Primitives, strings, arrays, and objects (and their generated `[ZodSchema]` validators) validate valid inputs without allocating. See [Performance](Performance.md) for the measurements. The exceptions are listed under [Limitations](#limitations).
6+
- **No reflection on hot paths.** The runtime library uses expression trees only in the opt-in `CompiledValidator` and to compile a one-off discriminator accessor per (type, discriminator) pair for `ZodDiscriminatedUnion`. After that first use, validation runs direct property access; the source generator emits direct typed codegen.
7+
- **Deterministic, reviewable generated code.** The `[ZodSchema]` generator output is stable and de-duplicated; there are no scope leaks in emitted code.
88
- **Cross-platform parity.** The C# implementation is exercised against TypeScript/Zod fixtures (see [Cross-Platform Interop](Cross-Platform-Interop.md)).
99
- **Multi-targeting.** Packages target `net8.0`, `net9.0`, and `net10.0`; the source generator targets `netstandard2.0` so it runs in any compiler host.
10-
- **Immutable, shareable schemas.** Composing returns new schemas; schemas are safe to cache and share across threads.
10+
- **A fully built schema is safe to cache and share across threads.** Validation only reads the rule set and `Description`, so once construction is finished a schema can be reused concurrently. Building is *not* immutable — see the next section.
1111

1212
## Limitations
1313

14-
### Attribute flags that are not yet honoured
14+
### Fluent rule methods mutate the receiver
1515

16-
`SchemaName`, `GenerateValidateMethod`, and `GenerateParseMethod` on `[ZodSchema]` are parsed but ignored: the schema class is always `{TypeName}Schema` and `Validate`/`Parse` are always emitted. `EnableComposition` and the `IValidateOptions` flags are honoured.
16+
`Min`, `Max`, `Email`, `Regex`, `UUID`, `StartsWith`, `EndsWith`, `Describe`, and the other fluent rule/description methods append to the receiver's rule list (or set its description) **in place** and return `this`. Only the composition methods (`Transform`, `Refine`, `SuperRefine`, `Pipe`, `Catch`, `Default`, `Prefault`, `And`, `Or`) return a new schema. Finish configuring a schema before sharing it; two pieces of code holding the same instance share its rules.
1717

18-
### Generated size-failure Origin
18+
### Span validation returns a string
1919

20-
The generator reports `Origin = "array"` for both arrays and collections — there is no `"collection"` origin in generated code, even though `ValidationError.Origin` supports it.
20+
`ZodString.ValidateSpan(ReadOnlySpan<char>)` validates the span directly whenever every accumulated rule implements `IStringValidationRule`, but its result carries a `string`, so a **successful** validation still allocates once when the value is materialised. Use `ZodString.IsValidSpan(ReadOnlySpan<char>, out ImmutableArray<ValidationError>)` when the value is not needed: it is allocation-free on success and only materialises the input when a rule (or a transform) cannot be evaluated over a span. `ValidateSpan` falls back to the string pipeline for schemas that involve string transforms (`Trim`, `ToLower`, `ToUpper`) or rules without a span implementation.
2121

22-
### Async validation is synchronous underneath
22+
### Rules without a span implementation
2323

24-
`ValidateAsync` wraps the synchronous `Validate` pipeline in a `ValueTask`. The only genuinely async path is the source generator's custom async validation method (`CustomValidationAsync`), which is awaited after the sync validation.
24+
`UrlRule` (its `Uri.TryCreate` fallback needs a string) and `Base64StringRule` (`Convert.FromBase64String`) do not implement `IStringValidationRule`; adding either to a `ZodString` disables the span fast path for that schema, and `ValidateSpan`/`IsValidSpan` fall back to materialising the input once.
2525

26-
### JSON Schema import scope
26+
### Size-failure Origin values
2727

28-
`Z.FromJsonSchema` supports **local** `$ref` (`#/...`) references only; external `$ref` targets throw `NotSupportedException`. `FromJsonSchemaOptions` is currently empty (reserved for future options).
29-
30-
### Referencing both JSON integration packages
31-
32-
`Purview.ZodSharp.SystemTextJson` and `Purview.ZodSharp.NewtonsoftJson` both declare types with identical full names (`ZodSharp.ZExtensions`, `ZodSharp.JsonSchema.FromJsonSchemaOptions`, `FromJsonSchemaParser`, `JsonSchemaSerializerOptions`). Reference one JSON integration package; referencing both requires `extern alias`.
28+
The generator reports `Origin = "string"` for string size failures, `Origin = "array"` for arrays, and `Origin = "collection"` for countable/`IEnumerable` collections. `ZodArray` reports `"array"`.
3329

3430
### Typed union allocation
3531

36-
`ZodTypedUnion`/`ZodUnion` allocate while attempting non-matching options and on failure. Discriminated unions dispatch directly and stay zero-allocation.
32+
`ZodTypedUnion`/`ZodUnion` allocate while attempting non-matching options and on failure; discriminated unions backed by dictionaries dispatch directly and stay zero-allocation.
3733

3834
### Rule errors
3935

40-
Rules evaluated by the base `Validate` pipeline produce `validation_failed` errors with an empty path. Structured `too_small`/`too_big` issues (with `Minimum`/`Maximum`/`Inclusive`) come from the array schema and the source generator's size validators.
36+
Rules evaluated by the base `Validate` pipeline produce `validation_failed` errors with an empty path. Structured `too_small`/`too_big` issues (with `Origin`, `Minimum`/`Maximum`, and `Inclusive`) are produced by `ZodArray` and by the source generator's size validators.
4137

4238
### String transforms allocate
4339

4440
`ToLower`, `ToUpper`, and `Trim` produce new strings on every validation (transform outputs are new strings by nature).
4541

46-
### `IStringValidationRule`
47-
48-
The span-based `IStringValidationRule` interface is declared but not implemented by any shipped rule struct; span validation is available through `ZodString.ValidateSpan`.
49-
5042
### Number semantics
5143

52-
`ZodNumber` operates on `double`. `Int()`, `Safe()`, and `Finite()` are validation rules, not conversions; `.Int()` rejects fractional values rather than rounding them. `MultipleOf` uses a tolerance-based comparison and rejects a zero divisor.
44+
`ZodNumber` operates on `double`. `Int()`, `Safe()`, and `Finite()` are validation rules, not conversions; `.Int()` rejects fractional values rather than rounding them. `Positive()`/`Negative()` are strict (they reject `0`; use `NonNegative()`/`NonPositive()` for inclusive bounds). `MultipleOf` compares the quotient to its nearest integer with a relative tolerance (`1e-12`), so `0.3` is accepted for `MultipleOf(0.1)` while `0.3000000001` is not; NaN and infinity are rejected, and a zero divisor throws `ArgumentException`.
5345

5446
### Enum semantics
5547

5648
`ZodEnum` (string values) and `ZodNativeEnum<TEnum>` validate against defined members; they do not parse or convert values.
5749

50+
### JSON Schema import scope
51+
52+
`Z.FromJsonSchema` supports **local** `$ref` (`#/...`) references only; external `$ref` targets throw `NotSupportedException`. `FromJsonSchemaOptions` is currently empty (reserved for future options).
53+
54+
### Referencing both JSON integration packages
55+
56+
`Purview.ZodSharp.SystemTextJson` and `Purview.ZodSharp.NewtonsoftJson` both declare types with identical full names (`ZodSharp.ZExtensions`, `ZodSharp.JsonSchema.FromJsonSchemaOptions`, `FromJsonSchemaParser`, `JsonSchemaSerializerOptions`). Reference one JSON integration package; referencing both requires `extern alias`.
57+
58+
## Custom rules
59+
60+
Custom rules and their DataAnnotations-style attributes are a first-class extension point. Rules can be attached to a property or to the schema type itself (validating the value object as a unit), and a generic rule can be closed with the target type so one rule serves every scalar of a given shape. See [Custom Rules](Custom-Rules.md) for the rule contract, the public `AddRule`/`Rule` API, and how to map a rule to a `ValidationAttribute` that the source generator honours.
61+
5862
## Contract vs. underlying libraries
5963

6064
- `System.ComponentModel.DataAnnotations` semantics are honoured where documented — e.g. `[Length]` treats `null` as valid unless `[Required]` is present; `[RegularExpression]` runs only on non-empty strings.

‎docs/wiki/Home.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ This wiki is the project documentation hub for the core API, source generator, J
2222
- [Arrays and Other Schemas](Arrays-and-Other-Schemas.md)
2323
- [Unions and Discriminated Unions](Unions-and-Discriminated-Unions.md)
2424
- [Composition and Transforms](Composition-and-Transforms.md)
25+
- [Custom Rules](Custom-Rules.md)
2526
- [Compiled Validators and Caching](Compiled-Validators-and-Caching.md)
2627
- [Dependency Injection](Dependency-Injection.md)
2728

‎docs/wiki/Number-Validation.md‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,24 @@ var result = schema.Validate(30.0);
1616
| `Min` | `Min(double minValue)` | `MinValueRule<double>` — `Value must be at least ...` |
1717
| `Max` | `Max(double maxValue)` | `MaxValueRule<double>` |
1818
| `Int` | `Int()` | `IntRule` — `value == Math.Truncate(value)` |
19-
| `Positive` | `Positive()` | `MinValueRule<double>(0.0)` |
20-
| `Negative` | `Negative()` | `MaxValueRule<double>(0.0)` |
21-
| `MultipleOf` | `MultipleOf(double divisor, string? message)` | `MultipleOfRule` — throws `ArgumentException` for a zero divisor; tolerance-based |
19+
| `Positive` | `Positive()` | `GreaterThanRule<double>(0.0)` — strictly greater than zero |
20+
| `Negative` | `Negative()` | `LessThanRule<double>(0.0)` — strictly less than zero |
21+
| `NonNegative` | `NonNegative()` | `MinValueRule<double>(0.0)` — greater than or equal to zero |
22+
| `NonPositive` | `NonPositive()` | `MaxValueRule<double>(0.0)` — less than or equal to zero |
23+
| `MultipleOf` | `MultipleOf(double divisor, string? message)` | `MultipleOfRule` — throws `ArgumentException` for a zero divisor; relative-tolerance comparison (`1e-12`) |
2224
| `Finite` | `Finite(string? message)` | `FiniteRule` — `double.IsFinite` |
2325
| `Safe` | `Safe(string? message)` | `SafeIntegerRule` — integer within `int.MinValue`..`int.MaxValue` |
2426

2527
## Examples
2628

2729
```csharp
28-
var positive = Z.Number().Positive();
29-
var negative = Z.Number().Negative();
30+
var positive = Z.Number().Positive(); // > 0
31+
var negative = Z.Number().Negative(); // < 0
32+
var nonNegative = Z.Number().NonNegative(); // >= 0
33+
var nonPositive = Z.Number().NonPositive(); // <= 0
3034
3135
var multipleOf = Z.Number().MultipleOf(10); // multiples of 10
36+
var fractional = Z.Number().MultipleOf(0.1); // 0.3 is accepted (floating-point tolerance)
3237
var finite = Z.Number().Finite(); // rejects Infinity / NaN
3338
var safe = Z.Number().Safe(); // safe integer range
3439
var whole = Z.Number().Int(); // no fractional part

‎docs/wiki/Performance.md‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,14 +90,17 @@ Numbers are indicative; re-run on your own hardware for local planning.
9090
9191
## Memory (`MemoryPerformanceTests`)
9292

93-
All valid-input paths are zero-allocation:
93+
All valid-input paths for the core schema types are zero-allocation:
9494

9595
| Scenario | Mean | Ratio |
9696
|---|---|---|
9797
| ValidateString_Allocations (baseline) | 45.82 ns | 1.00 |
9898
| ValidateObject_Allocations | 94.28 ns | 2.06 |
9999
| ValidateArray_Allocations | 1,180.24 ns | 25.82 |
100100

101+
> [!NOTE]
102+
> The string transforms, `ZodString.ValidateSpan` (its result carries a string), and non-first union options are the allocation exceptions. Use `ZodString.IsValidSpan` for an allocation-free span check. See [Guarantees and Limitations](Guarantees-and-Limitations.md).
103+
101104
## UUID validation (`UuidPerformanceTests`)
102105

103106
UUID validation uses a zero-allocation char-scan (version nibble at position 14, variant nibble at position 19) instead of a regex. Measured against the previous compiled regex:
@@ -119,9 +122,9 @@ The char-scan is ~20% faster than the previous regex on the valid path, is versi
119122
## Optimizations that make it fast
120123

121124
1. **Struct-based rules** — every rule is a `readonly record struct` implementing `IValidationRule<T>`, so there is no per-validation object allocation.
122-
2. **Zero-allocation helpers** — `Span<T>`/`ReadOnlySpan<T>` string validation (`ValidateSpan`) and `ArrayPool<T>`-backed helpers.
125+
2. **Span-aware helpers** — `Span<T>`/`ReadOnlySpan<T>` APIs and `ArrayPool<T>`-backed helpers. The shipped string rules implement `IStringValidationRule` (except the URL and Base64 rules, which need a string), so `ZodString.ValidateSpan` validates the span directly and only materialises the value string for its result; `ZodString.IsValidSpan` avoids that allocation entirely.
123126
3. **Compiled validators** — `CompiledValidator.Compile` removes interface dispatch (see [Compiled Validators and Caching](Compiled-Validators-and-Caching.md)).
124127
4. **Source generation** — `[ZodSchema]` emits direct property access and typed equality checks with no reflection (see [Source Generator](Source-Generator.md)).
125-
5. **Fluent composition** — schemas are immutable and shareable, so `SchemaCache` avoids repeated construction (see [Compiled Validators and Caching](Compiled-Validators-and-Caching.md)).
128+
5. **Cacheable schema instances** — a fully built schema is safe to cache and share, so `SchemaCache` avoids repeated construction (see [Compiled Validators and Caching](Compiled-Validators-and-Caching.md)). Building is not immutable: the fluent rule methods mutate the receiver. See [Custom Rules](Custom-Rules.md) for extending rules.
126129

127-
The only allocations on a successful validation are the string transforms (`ToLower`/`ToUpper`/`Trim` produce new strings) and the union non-first-option paths noted above.
130+
The allocations on a successful validation are limited to the string transforms (`ToLower`/`ToUpper`/`Trim`), `ZodString.ValidateSpan` (its result carries a string), and the union non-first-option paths.

‎docs/wiki/Source-Generator-DataAnnotations.md‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ Size attributes generate direct `Length` or `Count` access when possible:
3838
Structured size failures expose the same metadata as the runtime API:
3939

4040
- `Code`: `too_small` or `too_big`.
41-
- `Origin`: `string` for strings, `array` for arrays and collections.
41+
- `Origin`: `string` for strings, `array` for arrays, `collection` for countable/`IEnumerable` collections.
4242
- `Minimum` / `Maximum`: the inclusive bound.
4343
- `Inclusive`: `true`.
4444
- `Path`: the property path.
@@ -60,7 +60,7 @@ var result = BasketSchema.Validate(new Basket { Items = ["apple"] });
6060
```
6161

6262
> [!NOTE]
63-
> Today the generator reports `Origin = "array"` for both arrays and collections; there is no `"collection"` origin in generated code.
63+
> The generator reports `Origin = "array"` for arrays and `Origin = "collection"` for countable/`IEnumerable` collections.
6464
6565
## Range
6666

@@ -84,4 +84,8 @@ Misuse is reported at compile time rather than silently ignored:
8484
- String-only attributes (`[RegularExpression]`, `[EmailAddress]`, `[Url]`, `[Phone]`, `[CreditCard]`, `[Base64String]`) on non-string targets, `[AllowedValues]`/`[DeniedValues]` on unsupported types, or `[Range]` on unsupported types → ZODSGEN006.
8585
- `[Compare]` referencing an unknown property → ZODSGEN020.
8686

87-
See [Source Generator Diagnostics](Source-Generator-Diagnostics.md) for the full list.
87+
See [Source Generator Diagnostics](Source-Generator-Diagnostics.md) for the full list.
88+
89+
## Custom attributes
90+
91+
The same pipeline honours custom rules exposed as validation attributes. Mark the attribute with `[ZodRule(typeof(MyRule))]` (or mark the rule itself with `[ZodRule]` to have the attribute generated), and properties annotated with it are validated through the rule. See [Custom Rules](Custom-Rules.md).

‎docs/wiki/Source-Generator-Diagnostics.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ The `[ZodSchema]` generator ships an analyzer (category `ZodSharp.SourceGenerato
3232
| ZODSGEN027 | `IValidateOptions` requested but `Microsoft.Extensions.Options` reference is missing |
3333
| ZODSGEN028 | `IValidateOptions` requested on a struct (requires a class) |
3434
| ZODSGEN029 | A model declares both a synchronous refinement method and an async custom validation method (only one is allowed) |
35+
| ZODSGEN030 | A custom rule mapped through `[ZodRule(typeof(...))]` does not implement `IValidationRule<T>` for the property type (including an unbound generic rule that cannot be closed with it) |
36+
| ZODSGEN031 | A custom rule constructor parameter could not be mapped from the attribute (`[ZodRule]`) |
37+
| ZODSGEN032 | A validation attribute could not be generated for a rule marked `[ZodRule]` |
38+
| ZODSGEN033 | (warning) A rule-mapped attribute is applied to a type that gets no generated schema (no `[ZodSchema]` and not reachable as a complex property), so the rule never runs |
3539

3640
## Suppressing
3741

0 commit comments

Comments
 (0)