# ZodSharp > Zero-allocation schema validation for C# — a Zod port. A high-performance schema validation library for C#, ported from TypeScript Zod by way of the guinhx/ZodSharp C# port. Struct-based rules, a fluent API, JSON Schema export and import, source-generated validators, and zero-allocation validation on every valid input path. Integrates with System.Text.Json, Newtonsoft.Json, and ASP.NET Core ProblemDetails. ## Documentation version - Version: 3.0.0-prerelease.2 - Channel: prerelease - Source: purview-dev/zodsharp@main ## Other versions - 2.4.2: https://purview.dev/projects/zodsharp/llms.txt ## Resources - Repository: https://github.com/purview-dev/zodsharp - NuGet package: https://www.nuget.org/packages/Purview.ZodSharp - Project page: https://purview.dev/projects/zodsharp/ - Documentation: https://purview.dev/docs/zodsharp/prerelease/ - Full machine-readable content: https://purview.dev/projects/zodsharp/prerelease/llms-full.txt - Concise machine-readable index: https://purview.dev/projects/zodsharp/prerelease/llms.txt # Getting Started ## Install The packages target `net10.0` and `net11.0` (the .NET 11 integrations target `net11.0` only). Applications on `net8.0` or `net9.0` stay on the 2.4.x line. ```bash dotnet add package Purview.ZodSharp ``` Add the integration packages you need: ```bash # System.Text.Json integration + JSON Schema import dotnet add package Purview.ZodSharp.SystemTextJson # Newtonsoft.Json integration + JSON Schema import dotnet add package Purview.ZodSharp.NewtonsoftJson # ASP.NET Core ProblemDetails integration dotnet add package Purview.ZodSharp.AspNetCore ``` - `Purview.ZodSharp` — core library, source generator, and JSON Schema **export**. - `Purview.ZodSharp.SystemTextJson` — System.Text.Json deserialize-and-validate, validating converters, and JSON Schema **import**. - `Purview.ZodSharp.NewtonsoftJson` — Newtonsoft.Json deserialize-and-validate, validating converters, and JSON Schema **import**. - `Purview.ZodSharp.AspNetCore` — failed validation results converted to standard `ProblemDetails` / `HttpValidationProblemDetails` payloads. :::tip JSON Schema import (`Z.FromJsonSchema`) is provided by whichever JSON integration package you reference, so pick one, and import its JSON Schema namespace (`ZodSharp.JsonSchema.SystemTextJson` or `ZodSharp.JsonSchema.NewtonsoftJson`). Export (`Z.ToJsonSchema`) lives in the core package. ::: ## First schema ```csharp using ZodSharp; var nameSchema = Z.String().Min(3).Max(50); var result = nameSchema.Validate("John"); if (result.IsSuccess) Console.WriteLine($"Valid name: {result.Value}"); ``` ## Validate, SafeParse, and Parse - `Validate` returns a `ValidationResult` — no exceptions. - `SafeParse` is an alias of `Validate`. - `Parse` throws `ZodException` on failure. ```csharp var value = nameSchema.Parse("AB"); // throws ZodException var result = nameSchema.SafeParse("AB"); // non-throwing if (!result.IsSuccess) { foreach (var error in result.Errors) Console.WriteLine($" - {error.FullPath.ToDottedString()}: {error.Message}"); } ``` ## Object validation ```csharp var userSchema = Z.Object() .Field("name", Z.String().Min(1)) .Field("age", Z.Number().Min(0).Max(120).Int()) .Field("email", Z.String().Email()) .Build(); var userData = new Dictionary { { "name", "John Doe" }, { "age", 30.0 }, { "email", "john@example.com" } }; var result = userSchema.Validate(userData); ``` ## Source-generated validators Mark a class, struct, or record with `[ZodSchema]` and a zero-allocation static validator is generated at compile time: ```csharp using System.ComponentModel.DataAnnotations; using ZodSharp; [ZodSchema] public class User { [Required] [StringLength(50, MinimumLength = 3)] public string Name { get; set; } = string.Empty; [Range(0, 120)] public int Age { get; set; } [EmailAddress] public string? Email { get; set; } } var result = UserSchema.Validate(user); var validated = UserSchema.Parse(user); // throws on failure ``` See the [Source Generator](generator/source-generator/) and [Source Generator DataAnnotations](generator/source-generator-dataannotations/) pages for the full feature set. ## JSON integration ```csharp // System.Text.Json or Newtonsoft.Json var json = """{ "name": "John", "age": 30 }"""; var result = userSchema.DeserializeAndValidate(json); ``` ```csharp // JSON Schema export (core) var jsonSchema = Z.ToJsonSchema(userSchema, new ToJsonSchemaOptions { Title = "User" }); // JSON Schema import (requires an integration package) // using ZodSharp.JsonSchema.SystemTextJson; // or ZodSharp.JsonSchema.NewtonsoftJson var imported = Z.FromJsonSchema(jsonSchemaString); ``` See [System.Text.Json Integration](serialization/systemtextjson-integration/), [Newtonsoft.Json Integration](serialization/newtonsoftjson-integration/), [JSON Schema Export](serialization/jsonschema-export/), and [JSON Schema Import](serialization/jsonschema-import/). ## Where to go next This page is the entry point of the wiki, so the rest of it is grouped by what you are trying to do. Each group below is a section of the sidebar, in reading order; [Home](https://github.com/purview-dev/zodsharp/blob/main/docs/wiki/Home.md) lists every page in all seven sections. - **Learn the model** — [Core Concepts](concepts/core-concepts/) explains the pipeline, the result and the error model; [Fluent Schema API](fluent-schema-api/) is the surface every schema type shares. - **Pick a schema or a rule** — [Validation Rules Reference](schemas/validation-rules-reference/) lists every built-in rule, and each schema type has its own page: [String Validation](schemas/string-validation/), [Number Validation](schemas/number-validation/), [Object Validation](schemas/object-validation/), [Arrays and Other Schemas](schemas/arrays-and-other-schemas/), [Unions and Discriminated Unions](schemas/unions-and-discriminated-unions/), [Composition and Transforms](concepts/composition-and-transforms/). Write your own in [Custom Rules](schemas/custom-rules/). - **Generate validators** — [Source Generator](generator/source-generator/), then [Source Generator DataAnnotations](generator/source-generator-dataannotations/), with [Source Generator Diagnostics](generator/source-generator-diagnostics/) for the codes the build reports. - **Move data across a boundary** — [JSON Schema Export](serialization/jsonschema-export/) and [JSON Schema Import](serialization/jsonschema-import/) for interoperability, [System.Text.Json Integration](serialization/systemtextjson-integration/) and [Newtonsoft.Json Integration](serialization/newtonsoftjson-integration/) for deserialize-and-validate. - **Host it** — [ASP.NET Core Integration](aspnetcore-integration/), [Blazor Integration](hosting/blazor-integration/), [HttpClient Integration](hosting/http-integration/), [Dependency Injection](hosting/dependency-injection/), and [Localization](hosting/localization/) for messages. On .NET 11, [Microsoft.Extensions.Validation](hosting/microsoft-extensions-validation/) and [OpenAPI Integration](hosting/openapi-integration/) cover the platform pipeline. - **Ship it** — [Guarantees and Limitations](quality/guarantees-and-limitations/) states what is promised, [Performance](quality/performance/) what it costs, and [Trimming and Native AOT](quality/trimming-and-aot/) what the publish gates check. [Cross-Platform Interop](quality/cross-platform-interop/) covers the Zod parity fixtures. - **Work on it** — [Contributing](development/contributing/) and [Release Flow](development/release-flow/). --- # Core Concepts ## The validation pipeline Every schema derives from `ZodType` (namespace `ZodSharp.Core`). Validation is a two-phase pipeline: 1. **ParseInternal** — each schema overrides this hook to perform its type check and traversal (rejecting `null` where not allowed, coercing types, walking objects/arrays/tuples/unions, producing structured failures). 2. **Rules** — on success, the accumulated `IValidationRule` structs are evaluated. A failing rule emits a `ValidationError` carrying the rule's code (`too_small`, `too_big`, `not_multiple_of`, `not_finite`, `invalid_string`, `invalid_type`, `invalid_value`, …; `validation_failed` for custom rules without a code) and its structured identity: the Zod issue code as `Category`, an `Origin`, typed `Lower`/`Upper` bounds and parameters (see [Error fields](https://github.com/purview-dev/zodsharp/blob/main/docs/wiki/concepts#error-fields)). Each rule exposes its reported code and message as public `const string ErrorCode` / `MessageFormat` constants, so tests can assert against the rule instead of duplicating literals (a rule that omits them is reported as `ZODSGEN042` — see [Custom Rules](../../schemas/custom-rules/)). ```csharp ValidationResult result = schema.Validate(value); ``` ## Validate, SafeParse, Parse, ValidateAsync | Member | Behaviour | |---|---| | `Validate(TInput value)` | Returns a `ValidationResult`. Never throws. | | `SafeParse(TInput value)` | Alias of `Validate`. | | `Parse(TInput value)` | Returns the validated `TOutput`, or throws `ZodException` on failure (via `GetValueOrThrow()`). | | `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. | Reach for `Validate` even inside an async method: it is the cheaper call, and the analyzer package suppresses CA1849 for it (`ZODSUP001`). `ValidateAsync` is what runs a type's asynchronous rules, which is the only reason to await it. ## ValidationResult `ValidationResult` is a `readonly record struct` in `ZodSharp.Core`: - `bool IsSuccess` — annotated `[MemberNotNullWhen(true, nameof(Value))]`. - `T? Value` — the validated value; valid only when `IsSuccess`. - `ImmutableArray Errors` — populated on failure. Static factories: `Success(value)`, `Failure(ValidationError)`, `Failure(ImmutableArray)`, `Failure(IEnumerable)`, and `Merge(lhs, rhs)` (succeeds only if both succeed; concatenates errors). ## ValidationError `ValidationError` is a `readonly record struct` carrying machine-readable metadata about one issue. Equality compares every field, including the path segments and parameters by value, so two identical errors are equal. ## Error fields | Member | Meaning | |---|---| | `Code` | ZodSharp's specific identity: a rule's `ErrorCode` (`too_small`, `invalid_string`, `invalid_length`, …) or a schema code from `ZodIssueCodes` (`invalid_type`, `missing_field`, `invalid_union`, `unrecognized_keys`, `refinement_failed`, `transform_error`, …). The same failure reports the same code whichever producer reports it (runtime schema, generated validator, JSON Schema import). | | `Category` | The Zod v4 issue code (`ZodIssueCategories`): `invalid_type`, `too_small`, `too_big`, `invalid_format`, `not_multiple_of`, `unrecognized_keys`, `invalid_union`, `invalid_key`, `invalid_element`, `invalid_value`, `custom`. Every built-in error sets it, so a client can treat ZodSharp and Zod issues alike. | | `Message` | The rendered message, formatted with the invariant culture. Built-in messages never quote string input (see [PII](https://github.com/purview-dev/zodsharp/blob/main/docs/wiki/concepts#pii)). | | `MessageFormat` | The template that produced `Message`, with named placeholders (`"{displayName} is required"`), or `null` when the message was supplied literally. Hosts re-format it in the request culture (see [Localization](../../hosting/localization/)). | | `FullPath` | The authoritative, typed location (`ZodPath`); see [Paths](https://github.com/purview-dev/zodsharp/blob/main/docs/wiki/concepts#paths). | | `Path` | The legacy `ImmutableArray` projection of `FullPath`: members and keys as-is, indexes as digits (`["Items", "0", "Name"]`). Derived from `FullPath`, so the two never disagree. | | `Origin` | The kind of value that failed: `string`, `number`, `bigint`, `date`, `array`, `object`, `enum`, … | | `Lower` / `Upper` | `ErrorBound?` - the bound's `Value` (any comparable: a number, decimal or date) and whether it is `Inclusive`. `GreaterThan`/`LessThan` and exclusive `[Range]` bounds report `Inclusive = false`. | | `Minimum` / `Maximum` / `Inclusive` | The legacy integer bounds of size issues, kept for existing consumers. `Lower`/`Upper` fall back to them. | | `Parameters` | `ErrorTypeParameters?` (also an `IReadOnlyDictionary`) with the values behind the message. Well-known names are constants on `ZodIssueParameters`: `expected`, `received`, `options`, `keys`, `key`, `count`, `format`, `divisor`, `pattern`, `prefix`, `suffix`, `includes`, `version`, `discriminator`, `displayName`, `member`, `declaringType`, `unionErrors`. | ```csharp var error = Z.Number().Gt(5).Validate(1).Errors[0]; // error.Code == "too_small" // error.Category == ZodIssueCategories.TooSmall // error.Origin == "number" // error.Lower == new ErrorBound(5d, Inclusive: false) ``` The shared identities live in `ZodSharp.Core`, so every producer reports a failure the same way: | Type | Holds | |---|---| | `ZodIssueCodes` | The schema-level codes (`InvalidType`, `MissingField`, `RefinementFailed`, `InvalidUnion`, `MissingDiscriminator`, `InvalidDiscriminator`, `InvalidEnumValue`, `InvalidLiteral`, `InvalidTupleLength`, `UnrecognizedKeys`, `TooSmall`, `TooBig`, `TransformError`, …). | | `ZodIssueCategories` | The Zod v4 issue codes used as `Category`. | | `ZodMessages` | The default templates with named placeholders: `NullValue` (`"Expected {expected}, received null"`), `InvalidType` (`"Expected {expected}, received {received}"`), `Required` (`"{displayName} is required"`), `InvalidUnionOf`, `UnrecognizedKeys`, `ArrayTooSmall`, … | | `ZodIssues` | Factories for the common errors: `NullValue`, `InvalidType`, `MissingField`, `RefinementFailed`, `InvalidUnion`, `CollectionSize`, `Formatted`, plus the `GetUnionErrors()` extension. | | `ZodTypeNames` | Zod's type names (`string`, `number`, `bigint`, `boolean`, `date`, `object`, `array`, …) and `Of(Type)`/`OfValue(object?)`/`OfJsonKind(JsonValueKind)`, so CLR names such as `Int32` or `JsonElement` never reach a client. | A failed union reports one `invalid_union` error whose per-option issues are available through `error.GetUnionErrors()` (an `ImmutableArray>`, stored under the `unionErrors` parameter); see [Unions and Discriminated Unions](../../schemas/unions-and-discriminated-unions/). Create custom issues with `ValidationError.Create(code, message, path, parameters, origin, minimum, maximum, inclusive, category)`; `path` may be a `ZodPath`, an `ImmutableArray` or (through the constructor) a `string[]`. Fields not in the factory (`Lower`, `Upper`, `MessageFormat`, `Category`, `Origin`) are `init`-only, so set them with `with`: ```csharp var error = ValidationError.Create("out_of_stock", "Not enough stock", ZodPath.Member("Quantity")) with { Category = ZodIssueCategories.TooBig, Upper = ErrorBound.Including(10), }; ``` ## Paths `ZodPath` is an immutable sequence of typed `ZodPathSegment`s. Keeping the kind of each segment is what lets an index, a dictionary key and a member be told apart afterwards, so every host renders a location the same way. | Segment | Created with | Rendered | |---|---|---| | `ZodPathSegmentKind.Member` | `ZodPathSegment.Member("Items")` | `.Items` | | `ZodPathSegmentKind.Index` | `ZodPathSegment.At(0)` | `[0]` | | `ZodPathSegmentKind.Key` | `ZodPathSegment.Key("en")`, `ZodPathSegment.Key(42)` | `[en]`, or `["…"]` when ambiguous | `ZodPathSegment.Key(key)` renders the key with the invariant culture, so a path never depends on the thread's culture. `FullPath.ToDottedString()` is the canonical rendering, used for ProblemDetails keys, Microsoft.Extensions.Validation paths, Blazor field mapping, `ZodException` messages and options failures. It matches Microsoft.Extensions.Validation: members joined with `.` and indexes bracketed. Keys are bracketed too, and quoted when they would otherwise be ambiguous - an all-digit key (which would read back as an index) or a key containing `.`, `[`, `]`, quotes or whitespace. The root path renders as `""`. | Path | Dotted | |---|---| | member `Items`, index `0`, member `Name` | `Items[0].Name` | | member `Map`, key `en` | `Map[en]` | | member `Map`, key `"0"` | `Map["0"]` | | member `Map`, key `a.b`, member `Value` | `Map["a.b"].Value` | | root | `` (empty) | Other renderings and parsers: | Member | Output for `Map["a.b"].Items[0].Name` | |---|---| | `ToDottedString(IZodPathNamer? namer = null)` | `Map["a.b"].Items[0].Name` | | `ToJsonPointer(namer)` | `/Map/a.b/Items/0/Name` (RFC 6901 escaping) | | `ToJsonPath(namer)` | `$.Map['a.b'].Items[0].Name` | | `ToConfigurationKey()` | `Map:a.b:Items:0:Name` | | `ToZodPath()` | `["Map", "a.b", "Items", 0, "Name"]` (Zod's issue shape: integers for indexes) | | `ZodPath.ParseDotted(string)` | the inverse of `ToDottedString` (a bracketed all-digit value is an index, anything else a key) | | `ZodPath.ParseJsonPath(string)` | System.Text.Json (`$.items[1].age`) and Newtonsoft (`items[1].age`) paths | | `ZodPath.FromLegacy(IEnumerable)` | legacy segments; `"0"` and `"[0]"` become indexes | ```csharp var path = ZodPath.From(ZodPathSegment.Member("Items"), ZodPathSegment.At(0), ZodPathSegment.Member("Name")); path.ToDottedString(); // Items[0].Name path.ToDottedString(ZodPathNamer.FromFunc(JsonNamingPolicy.CamelCase.ConvertName)); // items[0].name ``` **Naming.** Member segments hold C# member names (or object keys for `Z.Object()` schemas). `ZodPathNaming` selects how hosts render them: `MemberName` (the default everywhere) keeps the C# name, which is what Microsoft.Extensions.Validation, MVC model state and Blazor's `FieldIdentifier` use, so a ZodSharp type and a DataAnnotations type in the same API produce the same key shape. `Json` renders JSON names; generated validators supply them without reflection through `IZodJsonPathMapper` (`[JsonPropertyName]` first, then the naming policy). Index and key segments are never renamed. **Re-pathing.** `error.WithPathPrefix(ZodPathSegment)` and `error.WithPathPrefix(ZodPath)` return a copy located under the prefix and keep every other field (origin, bounds, category, parameters, message format). They are the supported way to nest an error; do not rebuild an error from its code and message. :::note The legacy `Path` projection renders an index as `"0"`, matching Zod. Runtime `ZodArray`/`ZodTuple` used to produce `"[0]"`; parse `FullPath` rather than the strings if you need to tell indexes and keys apart. ::: ## Culture Validation is culture-independent; only the host that shows a message to a user applies a culture. | Data | Culture | |---|---| | `Code`, `Category`, `Origin`, parameter values, path key segments, bounds | Invariant, always | | `Message` produced at validation time | Invariant (deterministic and stable across machines) | | A message rendered by an `IZodMessageFormatter` at a host boundary | The culture the host passes - `CultureInfo.CurrentUICulture` in ASP.NET Core and Blazor | `IZodMessageFormatter.Format(in ValidationError, CultureInfo)` is the hook; `DefaultZodMessageFormatter` re-formats `MessageFormat` when every placeholder resolves and otherwise returns `Message`. The `ZodMessageFormat` engine behind it never throws: unknown placeholders and unbalanced braces are written literally, `{{`/`}}` escape braces, and named placeholders resolve from the parameters and then the built-in fields (`displayName`, `member`, `path`, `code`, `category`, `origin`, `minimum`, `maximum`, `inclusive`; `displayName` is the last member of the path, or `Value` at the root). A sequence parameter (an options or keys list) renders as its elements joined by `, `. See [Localization](../../hosting/localization/). ## PII Errors leave the process - in ProblemDetails, logs and UI - and the input may be a card number, a token or another secret. Built-in default messages therefore never quote the input - formats, patterns, prefixes, allowed and denied values, enums, literals, discriminators, numbers (`"Value must be between 1 and 10"`, `"Number must be a multiple of 5"`, `"Expected integer, received float"`) and JSON read failures describe what was expected, not what was received. Size rules state the count (`"…, but got 1"`), which is not the input. `ZodErrorOptions.IncludeReceivedValues` (process-wide, default `false`) adds the raw input as the `received` parameter of rule failures; with it off, rule failures carry no `received` parameter at all. It is for local development and diagnostics only: **never enable it in production**. Built-in messages stay value-free either way; a custom message can still use a rule's positional placeholders (the value is still passed, for example as `{1}` of `MinValueRule` or `{2}` of `RangeRule`). Every built-in rule reports its message template as `MessageFormat`, with named placeholders (`{minimum}`, `{maximum}`, `{count}`, `{divisor}`, `{pattern}`, …) that resolve from the error, so a host can localize a runtime schema's errors by template exactly as it does a generated validator's. A custom message is reported with its positional placeholders renamed (`"At least {0}"` becomes `"At least {minimum}"`); the value's position becomes `{received}`, which only resolves when `IncludeReceivedValues` is on, so a formatter falls back to `Message`. ## Depth limit Generated validators walk the object graph with a depth counter. `ZodValidationDepth.MaxDepth` (process-wide, default `ZodValidationDepth.DefaultMaxDepth`, `32`, matching Microsoft.Extensions.Validation's `ValidationOptions.MaxDepth`) is the limit: a graph nested deeper - almost always a cycle - throws `InvalidOperationException` instead of overflowing the stack. The root is validated at depth `0`. ## ZodException `ZodException` (namespace `ZodSharp.Core`) is thrown by `Parse` and `GetValueOrThrow()`. It exposes `ImmutableArray Errors`. Its `Message` lists up to five issues as `"{path}: {Message} ({Code})"` (the root path is shown as `(root)`) followed by `"and N more"`, and `ToString()` adds one line per error in the same form. ```csharp try { var value = Z.String().Min(3).Parse("A"); } catch (ZodException ex) { // ex.Message == "Validation failed with 1 error(s): (root): String must be at least 3 characters long, but got 1 (too_small)" foreach (var error in ex.Errors) Console.WriteLine($"{error.FullPath.ToDottedString()}: {error.Message}"); } ``` ## Schemas and rules - Schemas are classes deriving from `ZodType`; the fluent methods return `this` (or a wrapping schema) so chains read naturally. - Rules are `readonly record struct` implementations of `IValidationRule` (`bool IsValid(in T value)`, `string GetErrorMessage(in T value)`) — zero allocation. - Custom rules can be attached with the public `AddRule`/`Rule` methods and surfaced as DataAnnotations-style attributes; see [Custom Rules](../../schemas/custom-rules/). - `ValidateSpan(ReadOnlySpan value)` is available on `ZodString` for span-based validation; use `IsValidSpan(value, out errors)` for an allocation-free check. - A schema's `Description` is set with `.Describe("...")`. ## Composition model `ZodType` composes via wrappers rather than mutation. The base type guards that input and output types match, then returns a new schema: - `Transform(Func)` → `ZodTransform`. - `Refine(Func, string? message)` → `ZodRefinement`. - `SuperRefine(Action>)` → `ZodSuperRefinement`. - `Pipe(IZodSchema)` → `ZodPipe`. - `Catch(TOutput | Func, TOutput>)` → `ZodCatch`. - `Prefault(TOutput)` → `ZodPrefault`. - `Default(TOutput)` → `ZodDefault`. - `And(IZodSchema)` → `ZodIntersection`. - `Or(IZodSchema)` → `ZodTypedUnion`. See [Composition and Transforms](../composition-and-transforms/) for details and semantics. ## Optionality and null handling Schemas report optionality through the internal `IOptionalSchema` interface: - `IsOptional` — `true` for `ZodOptional`, `ZodNullable`, `ZodDefault`, `ZodPrefault`. - `ProvidesValueOnMissing` — `true` for `ZodDefault` and `ZodPrefault`; object fields route missing values through `Validate(null!)` to inject the produced value. - `IAcceptsNull.ValidateNull()` is implemented by nullable/optional/default/prefault schemas and `ZodNull`; object-field and union wrappers route `null` input to it instead of failing coercion. ## Type coercion Object fields and union options wrap typed schemas so boxed values from a `Dictionary` can be validated: - `null` routes to `IAcceptsNull.ValidateNull()` where supported. - Exact-typed values reuse the original boxed value. - Numeric coercion uses `IConvertible` (invariant culture), so a boxed `long` can satisfy a `Z.Number()` field. - Non-nullable value types reject `null`; anything else falls through to failure. ## Dependency injection `IZodSchemaFactory` is the registry that resolves validators by type. See [Dependency Injection](../../hosting/dependency-injection/). --- # Fluent Schema API ## The `Z` factory `public static class Z` (namespace `ZodSharp`) is the entry point for creating schemas. | Method | Signature | Returns | |---|---|---| | `String()` | `Z.String()` | `ZodString` | | `Number()` | `Z.Number()` | `ZodNumber` | | `Boolean()` | `Z.Boolean()` | `ZodBoolean` | | `Null()` | `Z.Null()` | `ZodNull` | | `Date()` | `Z.Date()` | `ZodDate` | | `DateTimeOffset()` | `Z.DateTimeOffset()` | `ZodDateTimeOffset` | | `BigInt()` | `Z.BigInt()` | `ZodBigInt` | | `Array` | `Z.Array(IZodSchema elementSchema)` | `ZodArray` | | `Optional` | `Z.Optional(IZodSchema schema)` — `T : class` | `ZodOptional` | | `Nullable` | `Z.Nullable(IZodSchema schema)` — `T : struct` | `ZodNullable` | | `Object()` | `Z.Object()` | `ZodObjectBuilder` | | `Union` (untyped) | `Z.Union(params IZodSchema[] options)` | `ZodUnion` | | `Union` (typed) | `Z.Union(IZodSchema, IZodSchema)` | `ZodTypedUnion` | | `Intersection` | `Z.Intersection(IZodSchema left, IZodSchema right)` | `ZodIntersection` | | `Literal` | `Z.Literal(T value)` — `T : IEquatable` | `ZodLiteral` | | `Lazy` | `Z.Lazy(Func> schemaGetter)` | `ZodLazy` | | `DiscriminatedUnion` | `Z.DiscriminatedUnion(string discriminator)` | `ZodDiscriminatedUnionBuilder` | | `Enum` (string values) | `Z.Enum(params string[] values)` | `ZodEnum` | | `Enum` (native) | `Z.Enum()` — `TEnum : struct, Enum` | `ZodNativeEnum` | | `Record` | `Z.Record(IZodSchema valueSchema)` | `ZodRecord` | | `Tuple` | `Z.Tuple(IZodSchema, IZodSchema)` | `ZodTuple` | | `Tuple` | `Z.Tuple(IZodSchema, IZodSchema, IZodSchema)` | `ZodTuple` | | `ToJsonSchema` | `Z.ToJsonSchema(IZodSchema schema, ToJsonSchemaOptions? options = null)` | `JsonSchemaDefinition` | :::note `Enum` and `Union` are overloaded: `Enum(params string[])` vs `Enum()`, and `Union(params ...)` vs `Union(...)`. `ToJsonSchema` lives in the core package; `FromJsonSchema` is an extension on `Z` provided by the JSON integration packages. ::: ## Schema type reference Each schema type has its own page: - [String Validation](../schemas/string-validation/) — `ZodString`. - [Number Validation](../schemas/number-validation/) — `ZodNumber`. - [Object Validation](../schemas/object-validation/) — `ZodObject`, `ZodObjectBuilder`. - [Arrays and Other Schemas](../schemas/arrays-and-other-schemas/) — `ZodArray`, `ZodBoolean`, `ZodNull`, `ZodEnum`, `ZodNativeEnum`, `ZodLiteral`, `ZodRecord`, `ZodTuple`, `ZodLazy`. - [Unions and Discriminated Unions](../schemas/unions-and-discriminated-unions/) — `ZodUnion`, `ZodTypedUnion`, `ZodDiscriminatedUnion`. - [Composition and Transforms](../concepts/composition-and-transforms/) — `ZodTransform`, `ZodRefinement`, `ZodSuperRefinement`, `ZodPipe`, `ZodCatch`, `ZodDefault`, `ZodPrefault`, `ZodIntersection`. - [Compiled Validators and Caching](../schemas/compiled-validators-and-caching/) — `CompiledValidator`, `SchemaCache`. ## The interfaces - `IZodSchema` — `Validate` / `ValidateAsync`; `IZodSchema` is the convenience form where input equals output. - `IZodSchemaValidator` (marker) and `IZodSchemaValidator` — the DI-facing adapter surface (see [Dependency Injection](../hosting/dependency-injection/)). - `IValidationRule` — the rule contract implemented by every struct rule. `ZodType.AddRule(rule)` and `Rule(rule)` are public, so custom rules can be attached to any schema, and a fluent extension method can give them a first-class method like the built-in rules. Every rule exposes its reported code and message template as public `const string ErrorCode` / `MessageFormat` constants (enforced by `ZODSGEN042`). `ZodString` also exposes `IsValidSpan`/`ValidateSpan` for span-based string validation, and string rules that implement `IStringValidationRule` participate in the span path. - `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. - `IStringValidationRule` — the span-based counterpart of `IValidationRule`. The shipped `ZodSharp.Rules` namespace provides the built-in rules (for example `EmailRule`, `MinLengthRule`, `NonSentinelRule`); each is a `readonly record struct` usable standalone or through the fluent API. Every rule has a fluent method on its schema — see [Validation Rules Reference](../schemas/validation-rules-reference/) for the catalogue. See [Custom Rules](../schemas/custom-rules/) for defining, attaching, and mapping rules (including generic rules, the `ErrorCode`/`MessageFormat` constants convention, and [fluent extension methods](../schemas/custom-rules/#extending-the-fluent-interface)). ## Convenience composition on any schema Because composition is implemented on the base `ZodType`, every schema can chain `.Describe(...)`, `.Transform(...)`, `.Refine(...)`, `.SuperRefine(...)`, `.Pipe(...)`, `.Catch(...)`, `.Prefault(...)`, `.Default(...)`, `.And(...)`, and `.Or(...)`. See [Composition and Transforms](../concepts/composition-and-transforms/). --- # String Validation `ZodString` (namespace `ZodSharp.Schemas`) validates `string` values. `ParseInternal` rejects `null` with an `invalid_type` error (`"Expected string, received null"`); on success the accumulated rules run. ```csharp using ZodSharp; var schema = Z.String().Min(3).Max(50).Email(); var result = schema.Validate("user@example.com"); ``` ## Methods | Method | Signature | Rule added | |---|---|---| | `Min` | `Min(int minLength)` | `MinLengthRule` — `too_small` when too short | | `Max` | `Max(int maxLength)` | `MaxLengthRule` | | `Length` | `Length(int length)` | exact length (both bounds) | | `Email` | `Email()` | `EmailRule` — static compiled regex | | `Regex` | `Regex(Regex pattern, string? message)` / `Regex(string pattern, string? message)` | `RegexRule`; the string overload compiles with a 100 ms timeout | | `Url` | `Url(string? message)` | `UrlRule` — regex or absolute `http`/`https` URI | | `Uri` | `Uri(string? message)` / `Uri(UriKind uriKind, string? message)` | `UriRule` — `Uri.TryCreate` against the supplied `UriKind` (defaults to `RelativeOrAbsolute`) | | `Phone` | `Phone(string? message)` | `PhoneRule` — digits plus `() .+-`, at least one digit | | `CreditCard` | `CreditCard(string? message)` | `CreditCardRule` — Luhn algorithm | | `Base64String` | `Base64String(string? message)` | `Base64StringRule` — `Convert.FromBase64String` | | `UUID` | `UUID(string? message)` | `UUIDRule` — char-scan, RFC 9562 versions 1-8, variant nibble `8-9/a-b`, plus nil and max | | `UUID` | `UUID(UuidVersion version, string? message)` | `UUIDRule` — requires a specific version (e.g. `V7`), variant nibble `8-9/a-b`, nil/max rejected | | `StartsWith` | `StartsWith(string prefix, StringComparison comparison = StringComparison.Ordinal, string? message, string? code)` | `StartsWithRule` — comparison defaults to `Ordinal` | | `EndsWith` | `EndsWith(string suffix, StringComparison comparison = StringComparison.Ordinal, string? message, string? code)` | `EndsWithRule` — comparison defaults to `Ordinal` | | `Includes` | `Includes(string substring, string? message, string? code)` | `IncludesRule` — ordinal substring containment | | `IP` | `IP(string? message, string? code)` / `IP(IPAddressRuleType ruleType, string? message, string? code)` | `IPAddressRule` — IPv4/IPv6, or only the requested family (`IPv4`/`IPv6`/`Any`; defaults to `Any`) | | `JWT` | `JWT(string? message)` | `JWTRule` — three base64url-encoded segments | | `Hex` | `Hex(string? message)` | `HexRule` — hexadecimal characters (empty string is valid, matching Zod) | | `Base64Url` | `Base64Url(string? message)` | `Base64UrlRule` — URL-safe base64, no padding (groups of 4 plus a 2-3 character tail) | | `ULID` | `ULID(string? message)` | `ULIDRule` — 26 Crockford base32 characters, first character `0`-`7` | | `Datetime` | `Datetime(string? message)` | `DatetimeStringRule` — ISO 8601 date-time (`yyyy-MM-ddTHH:mm:ss[.fff]Z`) | | `Date` | `Date(string? message)` | `DateStringRule` — ISO 8601 date (`yyyy-MM-dd`) | | `Time` | `Time(string? message)` | `TimeStringRule` — ISO 8601 time (`HH:mm`, optionally `:ss` and fractional seconds) | | `Nanoid` | `Nanoid(string? message)` | `NanoidRule` — 21 URL-safe characters | | `Cuid2` | `Cuid2(string? message)` | `Cuid2Rule` — lowercase alphanumeric characters | | `E164` | `E164(string? message)` | `E164Rule` — `+` followed by 7-15 digits | | `NonSentinel` | `NonSentinel(string? message)` | `NonSentinelRule` — rejects `null`/empty/whitespace; inherited from `ZodType` and overridden to keep `ZodString` in the chain | | `ToLower` | `ToLower()` | wraps a transform (`ToLowerInvariant`), returns a `ZodString` | | `ToUpper` | `ToUpper()` | wraps a transform (`ToUpperInvariant`) | | `Trim` | `Trim()` | wraps a transform (`Trim`) | | `ValidateSpan` | `ValidateSpan(ReadOnlySpan value)` | validates the span directly; a successful result materialises the value string | | `IsValidSpan` | `IsValidSpan(ReadOnlySpan value, out ImmutableArray errors)` | allocation-free on success; materialises the input only when a rule or transform has no span path | :::note `ToLower`, `ToUpper`, and `Trim` produce a new string on every validation. `IsValidSpan` does not allocate when the value is valid; `ValidateSpan` allocates once because its result carries a `string`. ::: Every method that adds a rule also accepts optional `string? message` and `string? code` parameters: `message` overrides the rule's default message, and `code` overrides the reported error code (otherwise the rule's own `ErrorCode` is reported). The rule's default message format is used when `message` is omitted. ## Examples ```csharp var email = Z.String().Email().Validate("user@example.com"); var url = Z.String().Url().Validate("https://example.com"); var uuid = Z.String().UUID().Validate("550e8400-e29b-41d4-a716-446655440000"); var uuidV7 = Z.String().UUID(UuidVersion.V7).Validate("0192b4c1-7a9b-7f5e-9a3c-2d4e6f8a0b1c"); var prefix = Z.String().StartsWith("https://"); var suffix = Z.String().EndsWith(".com"); var exact = Z.String().Length(10); var normalized = Z.String().Trim().ToUpper().Validate(" hello "); // "HELLO" ReadOnlySpan span = "user@example.com".AsSpan(); var spanResult = Z.String().Min(3).Max(50).Email().ValidateSpan(span); ``` ## Error messages Rules produce `ValidationError` entries with an empty path. Size validations emit `too_small` or `too_big` (origin `string`, typed bounds, the length in `count`); string-format validations emit `invalid_string` with category `invalid_format` and a `format` parameter (`email`, `uuid`, `regex`, `starts_with`, …). Default messages never quote the input. Many methods accept a custom `message` and `code` parameter. Rule structs live in `ZodSharp.Rules` and can be reused standalone with `IValidationRule`. For the full catalogue — including rules without a fluent method — see [Validation Rules Reference](../validation-rules-reference/). Every rule exposes its error identity as public constants — `public const string ErrorCode` and a `public const string MessageFormat` (a `{0}`-style template) — so tests and consumers can assert against the rule instead of duplicating literals: ```csharp var result = Z.String().Email().Validate("not-an-email"); // result.Errors[0].Code == EmailRule.ErrorCode ("invalid_string") // result.Errors[0].Message == EmailRule.MessageFormat ("Invalid email format") // result.Errors[0].Category == ZodIssueCategories.InvalidFormat ``` ## Span validation `ValidateSpan(ReadOnlySpan value)` validates the span directly using the rules' `IStringValidationRule` implementations; it materialises a `string` only for the returned value (and only falls back to the string pipeline for schemas with transforms or rules without a span implementation). `IsValidSpan(ReadOnlySpan value, out ImmutableArray errors)` is the allocation-free entry point when the value is not needed. An empty span is validated by the rules like an empty string. ## Custom rules Custom rules implement `IValidationRule` and can be attached with `Z.String().Rule(new MyRule())`. Implement `IStringValidationRule` as well to keep them on the span path. They can also be exposed as DataAnnotations-style attributes; see [Custom Rules](../custom-rules/). --- # Number Validation `ZodNumber` (namespace `ZodSharp.Schemas`) validates `double` values. `ParseInternal` rejects `double.NaN` with an `invalid_type` error (`"Expected number, received nan"`); on success the accumulated rules run. ```csharp using ZodSharp; var schema = Z.Number().Min(0).Max(120).Int(); var result = schema.Validate(30.0); ``` ## Methods | Method | Signature | Rule added | |---|---|---| | `Min` | `Min(double minValue)` | `MinValueRule` — `Value must be at least ...` | | `Max` | `Max(double maxValue)` | `MaxValueRule` | | `Gt` | `Gt(double value)` | `GreaterThanRule` — strictly greater than `value` | | `Gte` | `Gte(double value)` | `GreaterThanOrEqualRule` — greater than or equal to `value` | | `Lt` | `Lt(double value)` | `LessThanRule` — strictly less than `value` | | `Lte` | `Lte(double value)` | `LessThanOrEqualRule` — less than or equal to `value` | | `Int` | `Int()` | `IntRule` — `value % 1 == 0`; failure code `invalid_type` | | `Positive` | `Positive()` | `GreaterThanRule(0.0)` — strictly greater than zero | | `Negative` | `Negative()` | `LessThanRule(0.0)` — strictly less than zero | | `NonNegative` | `NonNegative()` | `MinValueRule(0.0)` — greater than or equal to zero | | `NonPositive` | `NonPositive()` | `MaxValueRule(0.0)` — less than or equal to zero | | `MultipleOf` | `MultipleOf(double divisor, string? message)` | `MultipleOfRule` — throws `ArgumentException` for a zero divisor; relative-tolerance comparison (`1e-12`) | | `Finite` | `Finite(string? message)` | `FiniteRule` — `T.IsFinite` | | `Safe` | `Safe(string? message)` | `SafeIntegerRule` — integer within `int.MinValue`..`int.MaxValue`; failure code `too_big` | | `Even` | `Even(string? message)` | `EvenRule` — `value % 2 == 0`; failure code `invalid_value` | | `Odd` | `Odd(string? message)` | `OddRule` — `value % 2 != 0`; failure code `invalid_value` | All six bound rules (`MinValueRule`, `MaxValueRule`, `GreaterThanRule`, `LessThanRule`, `GreaterThanOrEqualRule`, `LessThanOrEqualRule`) are generic over `T : IComparable`, and the arithmetic rules (`IntRule`, `FiniteRule`, `MultipleOfRule`, `EvenRule`, `OddRule`) are generic over `T : INumber`, so they close with any numeric type (`int`, `long`, `decimal`, …) — not just `double`. `ZodNumber` closes them with `double`; `ZodBigInt` and `ZodDate` close the bound rules with `long` and `DateTime`. `SafeIntegerRule` is intentionally `double`-only, because "safe integer" is a JavaScript `Number` concept. Every numeric method also accepts optional `string? message` and `string? code` parameters: `message` overrides the rule's default message, and `code` overrides the reported error code (otherwise the rule's own `ErrorCode` is reported). ## Examples ```csharp var positive = Z.Number().Positive(); // > 0 var negative = Z.Number().Negative(); // < 0 var nonNegative = Z.Number().NonNegative(); // >= 0 var nonPositive = Z.Number().NonPositive(); // <= 0 var multipleOf = Z.Number().MultipleOf(10); // multiples of 10 var fractional = Z.Number().MultipleOf(0.1); // 0.3 is accepted (floating-point tolerance) var finite = Z.Number().Finite(); // rejects Infinity / NaN var safe = Z.Number().Safe(); // safe integer range var whole = Z.Number().Int(); // no fractional part var even = Z.Number().Even(); // 0, 2, 4, ... var odd = Z.Number().Odd(); // 1, 3, 5, ... var age = Z.Number().Min(0).Max(120).Int().Validate(25.0); var atLeastTen = Z.Number().Gte(10); // >= 10 var atMostTen = Z.Number().Lte(10); // <= 10 ``` ## Numeric coercion When a `Z.Number()` is used as an object field or union option, boxed values are coerced via `IConvertible` (invariant culture) — for example a `long` from a `Dictionary` validates against a `Z.Number()` field. Non-numeric values fail with `invalid_type`. ## See also - [Validation Rules Reference](../validation-rules-reference/) — every built-in rule with its error code and message format. --- # Object Validation `ZodObject` (namespace `ZodSharp.Schemas`) validates `Dictionary` values. Build schemas with the `ZodObjectBuilder` returned by `Z.Object()`. ```csharp using ZodSharp; var userSchema = Z.Object() .Field("name", Z.String().Min(1)) .Field("age", Z.Number().Min(0).Max(120).Int()) .Field("email", Z.String().Email()) .Build(); var result = userSchema.Validate(new Dictionary { { "name", "John Doe" }, { "age", 30.0 }, { "email", "john@example.com" } }); ``` ## Behaviour - `null` input → `invalid_type` (`"Expected object, received null"`, template `ZodMessages.NullValue`). - Missing fields are allowed only when the key is optional (`Partial`/`Required`/`.Optional` semantics) or the field schema is itself optional (`IOptionalSchema.IsOptional`, e.g. `Z.Optional(...)`); otherwise `missing_field` (category `invalid_type`) at the key's path, with the message `"{key} is required"` (template `"{displayName} is required"`, `displayName` = the key). Generated validators and `RequiredRule` report the same code and template. - When a missing field's schema `ProvidesValueOnMissing` (e.g. `Z.Default(...)`), the produced value is injected into the output. - Field values are validated against their schema; failures are re-pathed under the field's member segment (`address.street`) and keep every field of the inner error. A value of the wrong type reports `"Expected string, received number"` with Zod type names in `expected`/`received`. Changed/coerced values trigger a rebuild of the output dictionary. - Unknown keys are handled by the object's `UnknownKeyPolicy` or `CatchallSchema` (below). ## Error paths Object errors use member segments for field keys, so `FullPath.ToDottedString()` renders `address.street`, and a nested array element `items[0].name`. An object key that contains `.`, brackets, quotes or whitespace is quoted (`["a.b"]`). See [Core Concepts: Paths](../../concepts/core-concepts/#paths) and [Error fields](../../concepts/core-concepts/#error-fields). ## Unknown key policies `UnknownKeyPolicy` is an enum with three values. `Strip` is the default. | Policy | Behaviour | |---|---| | `Strip` (default) | Unknown keys are dropped from the output. | | `Passthrough` | Unknown keys are kept as-is. | | `Strict` | Unknown keys fail with **one** `unrecognized_keys` issue at the object's own path, listing every unknown key in the `keys` parameter (`"Unrecognized keys: x, y"`). | ```csharp var strict = Z.Object().Field("name", Z.String()).Build().Strict(); var errors = strict.Validate(new Dictionary { ["name"] = "a", ["x"] = 1, ["y"] = 2 }).Errors; // errors[0].Code == ZodIssueCodes.UnrecognizedKeys ("unrecognized_keys"), errors[0].FullPath.IsRoot == true // errors[0].Parameters["keys"] holds ["x", "y"] var permissive = Z.Object().Field("name", Z.String()).Build().Passthrough(); ``` ## Catchall `Catchall(IZodSchema schema)` validates every unknown key against the schema and includes the validated value in the output. The schema argument must not be `null`. ```csharp var schema = Z.Object() .Field("name", Z.String()) .Catchall(Z.Number()) .Build(); ``` ## Fluent methods These return a **new** `ZodObject` instance: | Method | Behaviour | |---|---| | `Extend(string key, IZodSchema schema)` | add or replace a field | | `Merge(ZodObject other)` | other's shape overrides; adopts other's `UnknownKeyPolicy` + `CatchallSchema`; optionality per contributing object | | `Pick(params string[] keys)` | keep only the given keys | | `Omit(params string[] keys)` | remove the given keys | | `Partial()` | every shape key optional | | `Required()` | no optional keys; all shape keys required | | `Passthrough()` | `UnknownKeyPolicy.Passthrough` | | `Strict()` | `UnknownKeyPolicy.Strict` | | `Strip()` | `UnknownKeyPolicy.Strip` | | `Catchall(IZodSchema schema)` | validate unknown keys against a schema | ## Exposed shape `ZodObject` exposes `Shape`, `UnknownKeyPolicy`, `OptionalKeys`, `RequiredKeys`, and `CatchallSchema` as read-only properties, so metadata is inspectable (used by the JSON Schema exporter). ## Builder `ZodObjectBuilder` validates its arguments: a null/whitespace field name or a null schema throws `ArgumentNullException`. Typed fields are wrapped so boxed values coerce correctly (see [Core Concepts](../../concepts/core-concepts/)). --- # Arrays and Other Schemas ## Arrays `ZodArray` (namespace `ZodSharp.Schemas`) validates `T[]` values. Each element is validated against the element schema; failures are re-pathed under a typed index segment, keeping every field of the element's error, so `FullPath.ToDottedString()` renders `[1]` (or `items[1]` inside an object) and the legacy `Path` is `["1"]` (it used to be `["[1]"]`). A `null` array reports `"Expected array, received null"`. A rebuilt array is produced only when a transform changed an element. ```csharp using ZodSharp; var numbers = Z.Array(Z.Number()).Min(1).Max(10); var result = numbers.Validate(new[] { 1.0, 2.0, 3.0 }); ``` | Method | Behaviour | |---|---| | `Min(int minLength, string? message)` | `too_small` when count < min: `"Array must have at least 2 elements, but got 1"` | | `Max(int maxLength, string? message)` | `too_big` when count > max: `"Array must have at most 2 elements, but got 3"` | | `Length(int length, string? message)` | exact length (both bounds): `"Array must have exactly 2 elements, but got 3"` | | `NonEmpty(string? message)` | `minLength = 1` | Size failures carry origin `array`, both configured bounds as `Lower`/`Upper` (inclusive) and the element count in the `count` parameter. `Min`, `Max` and `Length` keep separate custom messages, so `Min(2, "few").Max(5, "many")` reports the message of the bound that failed. ## Boolean `ZodBoolean` validates `bool`; there are no fluent methods. ```csharp var schema = Z.Boolean(); ``` ## Null `ZodNull` succeeds only for `null` input, and implements `IAcceptsNull` so it can serve as an object field accepting `null`. ```csharp var schema = Z.Null(); ``` ## Date `ZodDate` validates `DateTime` values, with optional inclusive `.Min`/`.Max` bounds. Equivalent to Zod's `z.date()`. ```csharp var schema = Z.Date().Min(new DateTime(2020, 1, 1)).Max(new DateTime(2030, 12, 31)); ``` ## BigInt `ZodBigInt` validates 64-bit integers (`long`), with `.Min`, `.Max`, `.Gt`, `.Gte`, `.Lt`, `.Lte`, `.Positive`, `.Negative`, `.NonNegative`, and `.NonPositive`. Equivalent to Zod's `z.bigint()`. ```csharp var schema = Z.BigInt().Positive().Max(9_000_000_000); ``` ## Enum (string values) `ZodEnum` validates against a set of allowed strings. Failure produces `invalid_enum_value` (category `invalid_value`, origin `enum`) with the message `"Expected one of: admin, guest, user"` (options in ordinal order) and the `options` parameter; the received value is not echoed. ```csharp var schema = Z.Enum("admin", "user", "guest"); ``` ## Native enum `ZodNativeEnum` validates a native `System.Enum` using `Enum.IsDefined`. Failure produces `invalid_enum_value`, reported exactly as `EnumRule` reports it, with the options listed in ordinal order. ```csharp var schema = Z.Enum(); // Color : struct, Enum ``` ## Literal `ZodLiteral` (where `T : IEquatable`) accepts exactly one value. Failure produces `invalid_literal` (category `invalid_value`) with the message `"Expected literal active"` and the literal in the `expected` parameter; the received value is not echoed. ```csharp var schema = Z.Literal("active"); var schema2 = Z.Literal(42); ``` ## Record `ZodRecord` validates `Dictionary`, validating every value against the value schema. Errors are re-pathed under a typed key segment (rendered with the invariant culture): `[en]`, or `["0"]`/`["a.b"]` when the key would otherwise be ambiguous. It always produces a fresh validated dictionary. ```csharp var schema = Z.Record(Z.Number()); ``` A null value is the value schema's decision: `Z.Record(Z.String())` rejects it, while `Z.Record(Z.Optional(Z.String()))` accepts it. The schema exports and imports as `{ "type": "object", "additionalProperties": … }` (see [JSON Schema Export](../../serialization/jsonschema-export/#dictionaries)), and a generated `Dictionary` property is validated the same way. ## Tuple `ZodTuple` validates fixed-length tuples. Two- and three-element overloads exist; input is `object?[]`. ```csharp var schema = Z.Tuple(Z.String(), Z.Number()); var result = schema.Validate(new object?[] { "John", 30.0 }); // (string, double) success value ``` Failures: - `null` input → `invalid_type` (`"Expected tuple, received null"`). - Wrong length → `invalid_tuple_length` with category `too_small`/`too_big`, origin `array` and both bounds equal to the arity (`"Expected tuple of length 2, but got 3"`). - Element failures are prefixed with the element's index (`[0]`; legacy `Path` `["0"]`). A type mismatch reads `"Expected string, received number"`. They used to be reported at the tuple root. - A `null` element is handed to the element schema when its type can hold `null`, so `Z.Optional(...)` elements accept it. ## Lazy `ZodLazy` defers schema construction to first use, enabling recursive and circular schemas. The inner `Schema` is resolved lazily and thread-safely. ```csharp var categorySchema = Z.Lazy>(() => Z.Object() .Field("name", Z.String()) .Field("subcategories", Z.Array(categorySchema)) .Build()); ``` ## Optional / Nullable `Z.Optional(schema)` (`T : class`) accepts `null` or a value matching the inner schema. `Z.Nullable(schema)` (`T : struct`) is the value-type counterpart. ```csharp var optional = Z.Optional(Z.String()); optional.Validate(null); // Success optional.Validate("value"); // Success ``` For a generated collection, element nullability follows the element type: `T[]`/`List` rejects `null` elements, while `T?[]`/`List` accepts them — the same distinction as `z.array(T)` versus `z.array(T.nullable())`. A dictionary property (`IDictionary<,>`/`IReadOnlyDictionary<,>`) is a map instead: values are validated like elements, and it maps to `z.record(z.string(), V)`. See [Collection null items](../../generator/source-generator/#collection-null-items). --- # Unions and Discriminated Unions ## Untyped union `ZodUnion` (namespace `ZodSharp.Schemas`) tries each option in order and returns the first success. ```csharp using ZodSharp; var schema = Z.Union(Z.String(), Z.Number(), Z.Boolean()); var result = schema.Validate(42.0); // matches Z.Number() ``` When no option matches, a single `invalid_union` error (category `invalid_union`) is reported at the union's path. Its message names the options with Zod type names (`"Value does not match any of the union options (string, number)"`), the `options` parameter lists them, and the per-option issues - Zod's `unionErrors` - are available through `GetUnionErrors()`: ```csharp var error = Z.Union(Z.String().Min(5), Z.Number()).Validate("ab").Errors[0]; // error.Code == ZodIssueCodes.InvalidUnion foreach (ImmutableArray option in error.GetUnionErrors()) { // option 0: too_small (string must be at least 5 characters) // option 1: invalid_type ("Expected number, received string") } ``` `GetUnionErrors()` (on `ZodIssues`) returns an `ImmutableArray>`, one entry per option in declaration order, with paths relative to the union. It is stored under the `unionErrors` parameter (`ZodIssueParameters.UnionErrors`); the old mutable list under `errors`, which collided with the RFC 7807 `errors` member, is gone. Typed and native unions report the same shape (they used to discard the inner errors). ProblemDetails projects the union error at its own path, with `unionErrors` as nested issues. :::note On the failure path the accumulated option errors are collected, which allocates. Matching a later option (e.g. number or boolean after a string option) also allocates while the earlier options are attempted — see [Performance](../../quality/performance/). ::: ## Typed union `ZodTypedUnion` dispatches on runtime type (`is T1` / `is T2`) and produces a `Union` result value. ```csharp var schema = Z.Union(Z.String(), Z.Number()); var result = schema.Validate(42.0); if (result.IsSuccess) result.Value.Match( str => Console.WriteLine($"string: {str}"), num => Console.WriteLine($"number: {num}")); ``` ## Union and Union The `Union<...>` value type (namespace `ZodSharp.Unions`) is the result of a typed union: - `Create(T1)` / `Create(T2)` (and a three-case variant) — tagged construction. - Implicit conversions from the case types. - `int Tag` and `object Value` (`Value` throws if uninitialized). - `TryGetValue(out T1)` / `TryGetValue(out T2)`. - `Match(Func, Func)` and `Switch(Action, Action)`. - `==` / `!=`, `Equals`, `GetHashCode`, `ToString`. ## Native unions (.NET 11+) When targeting `net11.0` or later, `Z.NativeUnion` returns a native C# 15 union (`ZodSharp.Unions.NativeUnion`) instead of `Union`: ```csharp var schema = Z.NativeUnion(Z.String().Min(1), Z.Object().Build()); var result = schema.Validate("hello"); if (result.IsSuccess) { var length = result.Value switch { string s => s.Length, Dictionary o => o.Count, _ => -1, }; } ``` Prefer this when **both option types are reference types**: the native union is allocation-free and supports exhaustive pattern matching. Value-type cases box, so keep using `Z.Union` (the hand-rolled `Union`) when a case is a value type, or when you need `Tag`/`Match`/`Switch`/`TryGetValue`/equality. The analyzer reports `ZODSGEN041` (Info) and offers a code fix when a reference-type-only `Z.Union` can be switched to `Z.NativeUnion`. ## Discriminated union `ZodDiscriminatedUnion` dispatches on a discriminator value read from the input — a dictionary key or a public instance property — resolved case-insensitively. ```csharp var union = Z.DiscriminatedUnion("type") .Option("user", userSchema) .Option("admin", adminSchema) .Build(); var result = union.Validate(new Dictionary { { "type", "user" }, { "name", "John" } }); ``` Failures are reported at the discriminator's path, with category `invalid_union` and the `discriminator` and `options` parameters (options in declaration order). The received discriminator value is not echoed. | Failure | Code | Message | |---|---|---| | No discriminator present | `missing_discriminator` | `Discriminator field 'type' not found` | | Value not among the options | `invalid_discriminator` | `Invalid discriminator value. Expected one of: user, admin` | | `null` input | `invalid_type` | `Expected object, received null` (at the root) | Once an option is selected, its own errors are reported unchanged (for example `invalid_type` at `level`). Discriminated unions export to JSON Schema as `oneOf` (see [JSON Schema Export](../../serialization/jsonschema-export/)). The builder (`ZodDiscriminatedUnionBuilder`) accepts untyped `IZodSchema` options via `Option(string value, IZodSchema schema)` and typed options via `Option(string value, IZodSchema schema)`, which wrap the schema for coercion and `null` handling. ## Unions in generated validators and hosts The source generator validates a member whose type is a C# 15 union (`public union Pet(Cat, Dog);`) by dispatching to the active case's schema, and a member typed as a C# 15 `closed` class by dispatching to the derived type's schema. Paths do not include the union's `Value`: an error in the `Cat` case of a member `public Pet Pet { get; set; }` is reported at `Pet.Name`, not `Pet.Value.Name`. Every derived type of a closed hierarchy needs its own `[ZodSchema]`; one without is reported as `ZODSGEN049` (see [Source Generator Diagnostics](../../generator/source-generator-diagnostics/)). On .NET 11 the hosts understand unions too: - **Minimal APIs.** A JSON-body parameter typed as a union is validated through its active case, both by the Microsoft.Extensions.Validation pipeline (`AddZodSharpValidation()` claims union parameters without an application `AddValidation()` call) and by `WithZodSharpValidation()`. See [Microsoft.Extensions.Validation](../../hosting/microsoft-extensions-validation/) and [ASP.NET Core Integration](../../aspnetcore-integration/). - **Blazor.** A form model property of union type resolves its field through the case object, so messages land next to the right input. See [Blazor Integration](../../hosting/blazor-integration/). - **OpenAPI.** A C# union keeps the platform's `anyOf` shape and gets each case constrained; polymorphic (`[JsonPolymorphic]`) types are described with `oneOf` and a `discriminator`. See [OpenAPI Integration](../../hosting/openapi-integration/). ## Intersection `ZodIntersection` (created with `Z.Intersection(left, right)` or `.And(other)`) succeeds only when both schemas validate; failures merge both error sets, and an issue raised identically by both sides is reported once. ```csharp var schema = Z.String().Min(3).And(Z.String().Max(10)); ``` --- # Composition and Transforms Every schema derives from `ZodType`, so the composition methods below are available on every schema (guarded so they only apply when input equals output). Each method returns a **new** wrapping schema; the original is unchanged. ## Transform ```csharp var schema = Z.String().Transform(s => s.ToUpperInvariant()); var result = schema.Validate("hello"); // "HELLO" ``` `ZodTransform` validates the input schema first, then runs the transform. If the transform throws, the exception is caught into a `transform_error` `ValidationError` with category `custom` and the fixed message `"Transform failed."` (`ZodMessages.TransformFailed`). The exception's message is deliberately not reported, because it can disclose internal detail to an HTTP client. `OperationCanceledException`, `OutOfMemoryException` and `StackOverflowException` propagate. Transforms chain: ```csharp var schema = Z.String().Transform(s => s.Trim()).Transform(s => s.ToUpperInvariant()); ``` `ZodString` ships convenience transforms: `.ToLower()`, `.ToUpper()`, and `.Trim()`. ## Refine `ZodRefinement` runs the base schema first, then a predicate. A failing predicate produces code `refinement_failed` with category `custom` (custom `message` or `"Custom validation failed"`). The source generator's `ApplyAnd`/`ApplyOr`/`ApplyRefine` report the same code and category. ```csharp var even = Z.Number().Refine(n => n % 2 == 0, "Must be even"); ``` ## SuperRefine `ZodSuperRefinement` takes an `Action>` and can emit multiple, path-located issues. ```csharp var password = Z.String().SuperRefine(ctx => { if (!ctx.Value.Any(char.IsUpper)) ctx.AddIssue("Must contain an uppercase letter", new[] { "uppercase" }); if (ctx.Value.Length < 8) ctx.AddIssue("too_short", "Must be at least 8 characters", new[] { "length" }); }); ``` `RefineCtx` exposes: - `T Value` - the value being refined. - `ZodPath FullPath` - the typed base path under which issues are reported; `ImmutableArray Path` is its legacy projection. - `AddIssue(string code, string message, string[]? path = null)` - appends to the base path. Digit segments (or `"[0]"`) are treated as indexes. Throws `ArgumentException` for a null/whitespace code or message. - `AddIssue(string code, string message, ZodPath path, IReadOnlyDictionary? parameters = null, string? category = null)` - a typed relative path, optional parameters, and a category (`custom` when omitted). - `AddIssue(ValidationError issue)` - a fully specified issue (bounds, origin, parameters, message format), re-pathed under the base path with every field kept. - `AddIssue(string message, string[]? path = null)` - shorthand with code `refinement_failed`. - `Issues` / `HasIssues`. Every issue added through the context carries category `custom` unless you supply another. `new RefineCtx(value, ZodPath path)` creates a context directly, for example in a test. ```csharp var order = Z.String().SuperRefine(ctx => { ctx.AddIssue("invalid_sku", "Unknown SKU", ZodPath.From(ZodPathSegment.Member("Lines"), ZodPathSegment.At(0))); ctx.AddIssue( ValidationError.Create("out_of_stock", "Not enough stock", ZodPath.Member("Quantity")) with { Category = ZodIssueCategories.TooBig, Upper = ErrorBound.Including(10), } ); }); ``` ## Pipe `ZodPipe` runs the source schema, then validates its output against a target schema. ```csharp var schema = Z.String().Pipe(Z.String().Min(10)); ``` ## Catch `ZodCatch` swallows inner failures and returns a fallback as a **successful** result. The fallback can be a constant or a factory that receives the input and the errors. ```csharp var withFallback = Z.String().Catch("n/a"); var computed = Z.Number().Catch((value, errors) => 0); ``` ## Default `ZodDefault` substitutes a value when input is `null` and reports `IsOptional = true` and `ProvidesValueOnMissing = true`. The default is **not** re-validated. ```csharp var schema = Z.String().Default("unknown"); var result = schema.Validate(null); // "unknown" ``` ## Prefault `ZodPrefault` substitutes a value when the input equals `default(T)`, then **still validates** the substituted value through the inner schema. ```csharp var schema = Z.Number().Prefault(1); ``` ## And / Or - `.And(other)` → `ZodIntersection` — both must succeed; an issue raised identically by both sides is reported once (see [Unions and Discriminated Unions](../../schemas/unions-and-discriminated-unions/)). - `.Or(other)` → `ZodTypedUnion` — either may succeed. ## Example: layered validation ```csharp var schema = Z.String() .Min(3) .Transform(s => s.Trim()) .Refine(s => s.StartsWith("PUR-", StringComparison.Ordinal), "Must start with PUR-") .Default("PUR-UNKNOWN"); ``` ## Behavioral differences at a glance | Wrapper | Trigger | Re-validates substituted value | |---|---|---| | `Default(value)` | `null` input | No | | `Prefault(value)` | input equals `default(T)` | Yes | | `Catch(value\|factory)` | inner schema fails | No (returns fallback as success) | | `Refine(predicate)` | predicate returns false | — | | `SuperRefine(action)` | issues added to context | — | --- # Validation Rules Reference Every built-in validation rule lives in the `ZodSharp.Rules` namespace and is a `readonly record struct` implementing `ZodSharp.Core.IValidationRule`. Rules are the smallest unit of validation: a schema is a type/structural check followed by an ordered list of rules, and each failing rule adds a `ValidationError` to the result. This page is the catalogue of the rules that ship with the core package. For authoring your own, see [Custom Rules](../custom-rules/). ## The rule contract ```csharp namespace ZodSharp.Core; public interface IValidationRule { bool IsValid(in T value); string GetErrorMessage(in T value); string Code => "validation_failed"; } ``` - String rules additionally implement `ZodSharp.Core.IStringValidationRule` (`bool IsValid(ReadOnlySpan)` / `string GetErrorMessage(ReadOnlySpan)`) so `ZodString.ValidateSpan`/`IsValidSpan` can validate without materialising the input. - Every rule exposes its error identity as public constants: `public const string ErrorCode` and `public const string MessageFormat` (a `{0}`-style positional template). `Code` returns the rule's effective code — a per-usage `code` override when supplied, otherwise `ErrorCode` — and `GetErrorMessage` formats `MessageFormat` with `RuleMessage.Format` (which caches the parsed `CompositeFormat`) using the invariant culture. - A constructor `message` parameter, when supplied, overrides the formatted `MessageFormat`. `MessageFormat` is the fallback, not the only message; a rule built without a message and `default(TRule)` behave the same. A custom message may use the rule's positional placeholders, and formatting **never throws**: a stray brace or an index the rule does not supply is written out literally. - Default messages do not quote string input: format, prefix/suffix/substring, compare, allowed/denied and null-or-non-whitespace rules describe what was expected (`"String must start with 'PUR-'"`). Numeric rules still state the failing number and length rules the length. `RegexRule`'s `{0}` is the pattern. - Every built-in rule implements `IZodRule`, so its failure carries a full structured identity: `Origin` (`string`, `number`, `date`, `enum`, …), `Category` (the Zod issue code: `invalid_format` for string formats, `too_small`/`too_big` by direction for bound, length and range rules, `not_multiple_of`, `invalid_type` for `Int`, `invalid_value` for value lists), `Lower`/`Upper` bounds with the correct inclusivity (`GreaterThan`/`LessThan` are exclusive) and parameters such as `format`, `pattern`, `prefix`, `suffix`, `includes`, `divisor`, `options`, `version`, `expected` and `count`. See [Core Concepts: Error fields](../../concepts/core-concepts/#error-fields). - The analyzer reports `ZODSGEN042` when a source-declared rule omits `ErrorCode`/`MessageFormat`. See [Source Generator Diagnostics](../../generator/source-generator-diagnostics/). Rules can be used three ways: ```csharp using ZodSharp; using ZodSharp.Rules; // 1. Standalone var rule = new EmailRule(); if (!rule.IsValid("not-an-email")) Console.WriteLine(rule.GetErrorMessage("not-an-email")); // 2. Attached to a schema (AddRule or the generic Rule helper) var schema = Z.String().AddRule(new EmailRule()); var same = Z.String().Rule(new EmailRule()); // 3. Surfaced as a DataAnnotations attribute the [ZodSchema] generator honours // (built-in mappings, or a custom rule via [ZodRule]). See Source Generator DataAnnotations. ``` Tests can assert against the rule's constants instead of duplicating literals: ```csharp var result = Z.String().Email().Validate("not-an-email"); await Assert.That(result.Errors[0].Code).IsEqualTo(EmailRule.ErrorCode); // "invalid_string" await Assert.That(result.Errors[0].Message).IsEqualTo(EmailRule.MessageFormat); // "Invalid email format" await Assert.That(result.Errors[0].Category).IsEqualTo(ZodIssueCategories.InvalidFormat); ``` ## Error codes The built-in rules report one of the following codes as `Code`. `Category` always carries the matching Zod v4 issue code (`ZodIssueCategories`), so a client can switch on `Category` without knowing ZodSharp's specific codes: | Code | Meaning | Emitted by | |---|---|---| | `invalid_string` | A string did not match the required format | all string format rules | | `invalid_type` | A value was not the expected type/shape (e.g. not a whole number) | `IntRule` | | `too_small` | A length or value fell below a lower bound | `MinLengthRule`, `MinValueRule`, `GreaterThanRule` | | `too_big` | A length or value exceeded an upper bound | `MaxLengthRule`, `MaxValueRule`, `LessThanRule`, `SafeIntegerRule` (whose `Category` is `too_small` below the range and `invalid_type` for a fraction) | | `not_multiple_of` | A number was not a multiple of the divisor | `MultipleOfRule` | | `not_finite` | A number was `NaN` or infinite | `FiniteRule` | | `invalid_value` | A value was a rejected sentinel or was not in the allowed set | `NonSentinelRule`, `AllowedValuesRule`, `DeniedValuesRule` | | `invalid_enum_value` | A value was not a defined member of the enum type, or was an excluded member | `EnumRule` | | `missing_field` | A required value was absent (or empty/whitespace-only) | `RequiredRule` | | `invalid_length` | A string length fell outside the required range (`Category` `too_small`/`too_big` by direction) | `LengthRule`, `StringLengthRule`, and generated `[StringLength]`/`[Length]` on strings | | `invalid_range` | A comparable value fell outside the required range (`Category` `too_small`/`too_big` by direction) | `RangeRule` | | `mismatch` | A value did not equal the expected value | `CompareRule` | | `validation_failed` | Fallback for rules that do not declare an identity | any rule without `ErrorCode`/`IZodRule` | ## String rules Fluent methods live on `ZodString` (`ZodSharp.Schemas`). The `Span` column marks rules that implement `IStringValidationRule` and therefore stay on the zero-allocation span path. ### Length | Rule | Constructor | Fluent method | Code | Message format | Span | |---|---|---|---|---|---| | `MinLengthRule` | `(int minLength)` | `Min(n)`, `Length(n)` | `too_small` | `String must be at least {0} characters long, but got {1}` | yes | | `MaxLengthRule` | `(int maxLength)` | `Max(n)`, `Length(n)` | `too_big` | `String must be at most {0} characters long, but got {1}` | yes | `Z.String().Length(n)` adds **both** rules, so the string must be exactly `n` characters. ### Format | Rule | Constructor | Fluent method | Code | Message format | Span | |---|---|---|---|---|---| | `EmailRule` | `()` | `Email()` | `invalid_string` | `Invalid email format` | yes | | `RegexRule` | `(Regex pattern, string? message)` or `(string pattern, string? message)` | `Regex(...)` | `invalid_string` | `String does not match the required pattern: {0}` | yes | | `UrlRule` | `(string? message)` | `Url()` | `invalid_string` | `Invalid URL format` | no | | `UriRule` | `(UriKind uriKind, string? message)` | `Uri()`, `Uri(uriKind)` | `invalid_string` | `Invalid Uri, kind: {0}` | no | | `PhoneRule` | `(string? message)` | `Phone()` | `invalid_string` | `Invalid phone number format` | yes | | `E164Rule` | `(string? message)` | `E164()` | `invalid_string` | `Invalid E.164 phone number` | yes | | `CreditCardRule` | `(string? message)` | `CreditCard()` | `invalid_string` | `Invalid credit card number format` | yes | | `Base64StringRule` | `(string? message)` | `Base64String()` | `invalid_string` | `Invalid Base64 string format` | no | | `Base64UrlRule` | `(string? message)` | `Base64Url()` | `invalid_string` | `Invalid base64url string` | yes | | `UUIDRule` | `(string? message)` or `(UuidVersion version, string? message)` | `UUID()` / `UUID(version)` | `invalid_string` | `Invalid UUID format` (or `VersionedMessageFormat`, `Invalid UUID v{1} format`) | yes | | `ULIDRule` | `(string? message)` | `ULID()` | `invalid_string` | `Invalid ULID` | yes | | `JWTRule` | `(string? message)` | `JWT()` | `invalid_string` | `Invalid JWT` | yes | | `IPAddressRule` | `(string? message, string? code)` or `(IPAddressRuleType ruleType, string? message, string? code)` | `IP()` / `IP(ruleType)` | `invalid_string` | `Invalid {1} address` | yes | | `HexRule` | `(string? message)` | `Hex()` | `invalid_string` | `Invalid hexadecimal string` | yes | | `DateStringRule` | `(string? message)` | `Date()` | `invalid_string` | `Invalid date` | yes | | `TimeStringRule` | `(string? message)` | `Time()` | `invalid_string` | `Invalid time` | yes | | `DatetimeStringRule` | `(string? message)` | `Datetime()` | `invalid_string` | `Invalid date-time` | yes | | `NanoidRule` | `(string? message)` | `Nanoid()` | `invalid_string` | `Invalid nanoid` | yes | | `Cuid2Rule` | `(string? message)` | `Cuid2()` | `invalid_string` | `Invalid CUID2` | yes | | `StartsWithRule` | `(string prefix, StringComparison comparison = StringComparison.Ordinal, string? message, string? code)` | `StartsWith(prefix, comparison)` | `invalid_string` | `String must start with '{0}'` | yes | | `EndsWithRule` | `(string suffix, StringComparison comparison = StringComparison.Ordinal, string? message, string? code)` | `EndsWith(suffix, comparison)` | `invalid_string` | `String must end with '{0}'` | yes | | `IncludesRule` | `(string substring, string? message, string? code)` | `Includes(substring)` | `invalid_string` | `String must contain '{0}'` | yes | | `EmojiRule` | `(string? message)` | `Emoji()` | `invalid_string` | `Invalid emoji` | yes | | `XidRule` | `(string? message)` | `Xid()` | `invalid_string` | `Invalid XID` | yes | | `KsuidRule` | `(string? message)` | `Ksuid()` | `invalid_string` | `Invalid KSUID` | yes | | `DurationRule` | `(string? message)` | `Duration()` | `invalid_string` | `Invalid duration` | yes | | `GuidRule` | `(string? message)` | `Guid()` | `invalid_string` | `Invalid GUID` | yes | | `CidrRule` | `(CidrRuleType ruleType, string? message, string? code)` | `Cidr()` / `Cidr(ruleType)` | `invalid_string` | `Invalid {1} CIDR` | yes | Every rule constructor also accepts an optional `string? code = null` after `message`; when set it overrides the reported error code (otherwise the rule's own `ErrorCode` is reported). Behaviour notes: - **`EmailRule`** matches a compiled, case-insensitive regex (`^[^@\s]+@[^@\s]+\.[^@\s]+$`). - **`RegexRule`** uses the supplied `Regex`; the `Z.String().Regex(string)` overload compiles the pattern with a 100 ms timeout. Its message's `{0}` is the pattern, not the value. - **`UrlRule`** accepts the compiled HTTP(S) regex **or** an absolute `http`/`https` URI (`Uri.TryCreate`), so it may allocate when the fast regex misses. - **`UriRule`** validates with `Uri.TryCreate` against the supplied `UriKind` and therefore allocates. `Z.String().Uri()` defaults to `UriKind.RelativeOrAbsolute`; pass an explicit `UriKind` to require an absolute or relative URI. - **`PhoneRule`** mirrors `[Phone]`: digits plus `() .+-`, with at least one digit. - **`E164Rule`** requires `+`, a first digit `1`-`9`, and 7-15 digits in total. - **`CreditCardRule`** mirrors `[CreditCard]`: Luhn check, ignoring spaces and hyphens. - **`Base64StringRule`** mirrors `[Base64String]`: `Convert.FromBase64String` must succeed. - **`Base64UrlRule`** accepts URL-safe base64 (`A-Z a-z 0-9 - _`) with no padding; a length of `4n+1` is rejected. - **`UUIDRule`** accepts RFC 9562 versions 1-8 with a variant nibble of `8`-`9`/`a`-`b`, plus the nil and max UUIDs. The versioned constructor requires a specific version and rejects nil/max. It validates both a `string` and a `Guid` — a `Guid` is a UUID, so its bytes are inspected directly — which lets `[UUID(UuidVersion.Vx)]` constrain a `Guid` member or a `[Scalar]` value object. - **`ULIDRule`** requires 26 Crockford base32 characters, with the first character `0`-`7`. - **`JWTRule`** requires three non-empty base64url-encoded segments separated by periods. - **`IPAddressRule`** accepts IPv4 or IPv6 (`IPAddress.TryParse`); pass an `IPAddressRuleType` to require `IPv4`, `IPv6`, or `Any` (the default). - **`HexRule`** accepts any run of ASCII hex digits; the empty string is valid, matching Zod. - **`DateStringRule`** parses `yyyy-MM-dd` (invariant culture); **`TimeStringRule`** accepts `HH:mm`, optionally `:ss` and fractional seconds; **`DatetimeStringRule`** requires `yyyy-MM-dd` + `T` + `HH:mm:ss[.fff]` + `Z`. - **`NanoidRule`** requires exactly 21 URL-safe characters; **`Cuid2Rule`** requires non-empty lowercase alphanumerics. - **`StartsWithRule`** and **`EndsWithRule`** use the supplied `StringComparison` (default `Ordinal`); **`IncludesRule`** uses ordinal comparison. - **`EmojiRule`** mirrors Zod's `emoji()`: every rune must be an `Extended_Pictographic` or `Emoji_Component` code point, and at least one must be an anchor (a pictograph, a regional indicator, or a keycap). The Unicode tables are generated from Unicode 16.0 `emoji-data.txt`, because .NET's regex engine does not expose the `Extended_Pictographic` property. - **`XidRule`** requires exactly 20 base32hex characters (`0-9`, `a-v`/`A-V`); **`KsuidRule`** requires exactly 27 base62 characters. - **`DurationRule`** accepts ISO 8601-1 durations; the 8601-2 extensions (negative durations and fractional/negative components) are rejected, matching Zod. - **`GuidRule`** accepts any 8-4-4-4-12 hexadecimal identifier, including nil and max; `UUIDRule` additionally checks the RFC 9562 version and variant nibbles. A C# `Guid` is exported to JSON Schema as a canonical hexadecimal `pattern` (not `format: uuid`), because `Guid` accepts any 128-bit value; a `Guid` that must be an RFC version opts in with `[UUID(UuidVersion.Vx)]`. - **`CidrRule`** accepts an IPv4 or IPv6 block; pass a `CidrRuleType` to require `IPv4` or `IPv6` (`Any` is the default). ## Number rules Fluent methods live on `ZodNumber` (`ZodSharp.Schemas`), which validates `double`. | Rule | Constructor | Fluent method | Code | Message format | |---|---|---|---|---| | `MinValueRule` | `(T minValue)` | `Min(v)`, `NonNegative()` | `too_small` | `Value must be at least {0}` | | `MaxValueRule` | `(T maxValue)` | `Max(v)`, `NonPositive()` | `too_big` | `Value must be at most {0}` | | `GreaterThanRule` | `(T exclusiveMinimum)` | `Gt(v)`, `Positive()` | `too_small` | `Value must be greater than {0}` | | `LessThanRule` | `(T exclusiveMaximum)` | `Lt(v)`, `Negative()` | `too_big` | `Value must be less than {0}` | | `GreaterThanOrEqualRule` | `(T minValue, string? message)` | `Gte(v)` | `too_small` | `Value must be greater than or equal to {0}` | | `LessThanOrEqualRule` | `(T maxValue, string? message)` | `Lte(v)` | `too_big` | `Value must be less than or equal to {0}` | | `EvenRule` | `(string? message)` | `Even()` | `invalid_value` | `Number must be even` | | `OddRule` | `(string? message)` | `Odd()` | `invalid_value` | `Number must be odd` | | `MultipleOfRule` | `(T divisor, string? message)` | `MultipleOf(divisor)` | `not_multiple_of` | `Number must be a multiple of {0}` | | `FiniteRule` | `(string? message)` | `Finite()` | `not_finite` | `Number must be finite` | | `SafeIntegerRule` | `(string? message)` | `Safe()` | `too_small`/`too_big` by direction, `invalid_type` for a fraction | `Number must be a safe integer` | | `IntRule` | `()` | `Int()` | `invalid_type` | `Expected integer, received float` | Behaviour notes: - All six bound rules (`MinValueRule`, `MaxValueRule`, `GreaterThanRule`, `LessThanRule`, `GreaterThanOrEqualRule`, `LessThanOrEqualRule`) are generic over `T : IComparable`, so they can be reused with any comparable type (the fluent methods close them with `double`). `ZodBigInt` and `ZodDate` close the generic rules with `long` and `DateTime`. - The arithmetic rules (`IntRule`, `FiniteRule`, `MultipleOfRule`, `EvenRule`, `OddRule`) are generic over `T : INumber`, so they close with `int`, `long`, `double`, `decimal`, and every other numeric type; the fluent methods close them with `double`. - `Positive()` is `GreaterThanRule(0.0)`, `Negative()` is `LessThanRule(0.0)`, `NonNegative()` is `MinValueRule(0.0)`, and `NonPositive()` is `MaxValueRule(0.0)`. - **`EvenRule`** and **`OddRule`** treat a value as even when `value % 2 == 0`. - **`MultipleOfRule`** throws `ArgumentException` when the divisor is `0`, and compares the distance to the nearest multiple against a relative tolerance of `1e-12` (so `0.3` is accepted for a divisor of `0.1`). Integer types cannot represent the tolerance, so their comparison is exact. - **`FiniteRule`** rejects `NaN` and infinities via `T.IsFinite`. - **`SafeIntegerRule`** is intentionally `double`-only ("safe integer" is a JavaScript `Number` concept); it requires a whole number within `int.MinValue`..`int.MaxValue`. - **`IntRule`** requires `value % T.One == T.Zero`, which also rejects infinities (a truncate comparison would accept them). No default number message contains the input. Each rule still passes the value to a custom message, after the arguments its default uses: `{1}` for the bound rules and `MultipleOfRule`, `{2}` for `RangeRule`, and `{0}` for `IntRule`, `FiniteRule`, `SafeIntegerRule`, `EvenRule` and `OddRule`. The reported `MessageFormat` is the named form (`Value must be at least {minimum}`, `Number must be a multiple of {divisor}`), in which the value's position becomes `{received}`. ## Value rules | Rule | Constructor | Fluent method | Code | Message format | |---|---|---|---|---| | `NonSentinelRule` | `(string? message)` | `NonSentinel()` | `invalid_value` | `Value has a sentinel value, but got {0}` | | `NullOrNonWhiteSpaceRule` | `(string? message, string? code)` | — | `invalid_string` | `Value must be null or non-whitespace` | `NonSentinelRule` rejects the framework default/boundary values an ORM commonly stores to mean "no value": `Guid.Empty`; `DateTime`, `DateTimeOffset`, `DateOnly`, and `TimeOnly` `MinValue`/`MaxValue`; and `null`/empty/whitespace strings. Types without a known sentinel always pass, so the rule never rejects a type it does not understand. It detects sentinels with a `typeof(T)` dispatch and reinterprets the value in place, so no boxing occurs. The `{0}` argument is a **description** of the sentinel (`an empty GUID`, `DateTime.MinValue`, `an empty string`, …) rather than the raw value, and a type-level `[NonSentinel]` on a `[Scalar]` type replaces the message's `Value` subject with the scalar's name — see [Value Objects Integration](../value-objects-integration/#message-overrides). Its JSON Schema mapping is exact for `DateOnly`, `TimeOnly`, the deterministic `DateTime` wire forms, and `DateTimeOffset` (the default pattern is offset-aware); the optional UTC contract narrows the accepted offset — see [JSON Schema Export](../../serialization/jsonschema-export/#temporal-sentinels-and-the-datetimeoffset-wire-form). `NullOrNonWhiteSpaceRule` is the null-tolerant counterpart: it accepts `null` but rejects an empty or whitespace-only string. It exists for nullable scalar value objects, which round-trip JSON `null`, so their schema must accept `null` while still rejecting whitespace — `[RequiredZod]` always rejects `null` and so cannot express it. Apply it to a nullable scalar type with `[NullOrNonWhiteSpace]`; a rule written against the underlying `string?` value is adapted to the value object automatically. The fluent `NonSentinel()` method is declared on `ZodType`, so it is available on every schema and closes the rule with the schema's output type. `ZodString`, `ZodDate`, and `ZodDateTimeOffset` override it with a covariant return type so the fluent chain keeps the concrete schema: ```csharp var schema = Z.Date().NonSentinel(); var result = schema.Validate(DateTime.MinValue); // result.Errors[0].Code == NonSentinelRule.ErrorCode ("invalid_value") var offsetSchema = Z.DateTimeOffset().NonSentinel(); var offsetResult = offsetSchema.Validate(DateTimeOffset.MinValue); // Covariant overrides keep the concrete schema, so later fluent calls still compile. var chained = Z.String().NonSentinel().Min(3); ``` The same rule can be attached directly with `AddRule`/`Rule`, or through the shipped `[NonSentinel]` attribute, which closes the open generic with the annotated member/scalar type — see [Custom Rules](../custom-rules/#non-sentinel-values-ef-friendly). ## DataAnnotations-mirroring rules These rules mirror `System.ComponentModel.DataAnnotations` attributes as first-class rules, so the same behaviour is available standalone, through the fluent API, or through a generated attribute: | Rule | Constructor | Fluent method | Code | Message format | |---|---|---|---|---| | `RequiredRule` | `(bool allowEmptyStrings = false, bool trimWhitespace = false, string? message, string? code)` | `ZodString.Required(...)` | `missing_field` | `{0} is required` (`{0}` is the display name, `Value` when unknown) | | `RangeRule` | `(T minimum, T maximum, bool minimumIsExclusive = false, bool maximumIsExclusive = false, string? message, string? code)` | `ZodNumber.Range(...)`, `ZodBigInt.Range(...)`, `ZodDate.Range(...)` | `invalid_range` | `Value must be between {0} and {1}` | | `LengthRule` | `(int minimum, int maximum, string? message, string? code)` | `ZodString.Length(min, max)` | `invalid_length` | `String length must be between {0} and {1} characters, but got {2}` | | `StringLengthRule` | `(int maximumLength, int minimumLength = 0, string? message, string? code)` | `ZodString.StringLength(max, min)` | `invalid_length` | `String must be at most {0} characters long and at least {1}, but got {2}` | | `CompareRule` | `(string other, StringComparison comparison = Ordinal, string? message, string? code)` | `ZodString.Compare(...)` | `mismatch` | `Value does not match the expected value` | | `AllowedValuesRule` | `(T[] values, string? message, string? code)` | `AllowedValues(values)` | `invalid_value` | `Value is not one of the allowed values` | | `DeniedValuesRule` | `(T[] values, string? message, string? code)` | `DeniedValues(values)` | `invalid_value` | `Value is one of the denied values` | `RequiredRule` always rejects `null`. Its default message is `"Value is required"` and it reports the shared `"{displayName} is required"` template (`ZodMessages.Required`) as `MessageFormat`, so a host that knows the member can re-render it with the display name. When `allowEmptyStrings` is `false` an empty string is rejected as well; `trimWhitespace` additionally treats a whitespace-only string as empty (leading/trailing whitespace on a non-empty value is preserved). The `ZodString.Required` fluent method is a covariant convenience over the generic rule. `AllowedValuesRule` and `DeniedValuesRule` accept both a single value (`new AllowedValuesRule(5)`) and an array. The generated `[AllowedValuesZod]` / `[DeniedValuesZod]` attributes surface the array as a `params object[]` (plus a single-value overload), so `[AllowedValuesZod("a", "b", "c")]` works on a `string` member and `[DeniedValuesZod(1, 2, 3)]` on an `int` member; the resolver converts each element to the member type. ## Enum rules | Rule | Constructor | Fluent method | Code | Message format | |---|---|---|---|---| | `EnumRule` | `()` or `(TEnum[]? disallowed, string? message, string? code)` | `Enum()` | `invalid_enum_value` | `Value is not a defined member of {1}` | `EnumRule` is generic over `TEnum : struct, Enum` and rejects a value that is not a defined member of the enum type, or that resolves to a member the rule excludes. It is equivalent to Zod's `z.nativeEnum(Enum)` semantics. The defined members are resolved once per closed generic type, so the validation path performs no reflection. The `[ZodSchema]` generator emits this rule automatically for every non-flags enum property — see [Source Generator](../../generator/source-generator/#automatic-enum-validation). The rule is closed with the property's enum type and receives the excluded members as its disallowed set: - every enum member marked `[ZodIgnore]` (a member that is defined but never a valid value, such as `Unspecified`), and - the values the property's `[DeniedValues]` attribute lists. ```csharp using System.ComponentModel.DataAnnotations; using ZodSharp; public enum ExampleEnum { [ZodIgnore] Unspecified, AValidValue, AnotherValidValue, } [ZodSchema] public class Model { public ExampleEnum Status { get; set; } [DeniedValues(ExampleEnum.AnotherValidValue)] public ExampleEnum SecondaryStatus { get; set; } } ``` The rule is skipped for a `[Flags]` enum (a combination is valid without being a defined member), for a property that declares an explicit `[AllowedValues]` allow-list (the allow-list governs), and when the schema opts out with `[ZodSchema(ValidateEnumValues = false)]`. A hand-written `ZodNativeEnum` schema already rejects undefined members; its `Enum()` method adds this rule to customise the message/code, and the rule can be attached to any enum-output schema with `AddRule`/`Rule`. ## Generated attributes Each built-in rule that can be expressed as an attribute ships a generated `ValidationAttribute` in the `ZodSharp.Rules` namespace (inside the `Purview.ZodSharp` assembly), so a member or a scalar can be annotated directly: | Attribute | Rule | Notes | |---|---|---| | `[Email]` | `EmailRule` | | | `[E164]` | `E164Rule` | | | `[Regex("…")]` | `RegexRule` | mirrors the `(string pattern, string? message)` overload | | `[UUID]` / `[UUID(UuidVersion.…)]` | `UUIDRule` | the version is optional; omit it for the versionless rule | | `[ULID]` | `ULIDRule` | | | `[JWT]` | `JWTRule` | | | `[IPAddress]` / `[IPAddress(IPAddressRuleType.…)]` | `IPAddressRule` | defaults to `Any`; pass a type to require IPv4/IPv6 | | `[Hex]` | `HexRule` | | | `[Base64Url]` | `Base64UrlRule` | | | `[Nanoid]` | `NanoidRule` | | | `[Cuid2]` | `Cuid2Rule` | | | `[DateString]` | `DateStringRule` | | | `[TimeString]` | `TimeStringRule` | | | `[DatetimeString]` | `DatetimeStringRule` | | | `[StartsWith("…")]` | `StartsWithRule` | | | `[EndsWith("…")]` | `EndsWithRule` | | | `[Includes("…")]` | `IncludesRule` | | | `[MultipleOf(…)]` | `MultipleOfRule` | closes the open generic with the member type | | `[Finite]` | `FiniteRule` | closes the open generic with the member type | | `[SafeInteger]` | `SafeIntegerRule` | | | `[Int]` | `IntRule` | closes the open generic with the member type | | `[Uri(UriKind.…)]` | `UriRule` | the `UriKind` is required | | `[NonSentinel]` | `NonSentinelRule` | closes the open generic with the member/scalar type | | `[NullOrNonWhiteSpace]` | `NullOrNonWhiteSpaceRule` | accepts `null` but rejects empty/whitespace-only strings (for nullable scalar value objects) | | `[Enum]` | `EnumRule` | closes the open generic with the enum member type; excludes only the members the rule receives | | `[MinValue(…)]` | `MinValueRule` | the type-parameter bound is a `double`; the rule closes with the member type | | `[MaxValue(…)]` | `MaxValueRule` | as above | | `[GreaterThan(…)]` | `GreaterThanRule` | as above | | `[LessThan(…)]` | `LessThanRule` | as above | | `[GreaterThanOrEqual(…)]` | `GreaterThanOrEqualRule` | as above | | `[LessThanOrEqual(…)]` | `LessThanOrEqualRule` | as above | | `[Even]` | `EvenRule` | closes the open generic with the member type | | `[Odd]` | `OddRule` | closes the open generic with the member type | | `[MinCount(…)]` | `MinCountRule` | collection/dictionary minimum element count; the trim-safe `[MinLength]` on a collection, with the same error identity | | `[MaxCount(…)]` | `MaxCountRule` | collection/dictionary maximum element count; the trim-safe `[MaxLength]` on a collection | | `[CountRange(…, …)]` | `CountRangeRule` | inclusive element-count range; the trim-safe `[Length]` on a collection | | `[MinLengthZod(…)]` | `MinLengthRule` | suffixed to avoid the DataAnnotations name; exposes `Code`/`Message` | | `[MaxLengthZod(…)]` | `MaxLengthRule` | as above | | `[UrlZod]` | `UrlRule` | as above | | `[PhoneZod]` | `PhoneRule` | as above | | `[CreditCardZod]` | `CreditCardRule` | as above | | `[Base64StringZod]` | `Base64StringRule` | as above | | `[Emoji]` | `EmojiRule` | | | `[Xid]` | `XidRule` | | | `[Ksuid]` | `KsuidRule` | | | `[Duration]` | `DurationRule` | | | `[Guid]` | `GuidRule` | accepts any 8-4-4-4-12 hex identifier | | `[Cidr]` / `[Cidr(CidrRuleType.…)]` | `CidrRule` | defaults to `Any`; pass a type to require IPv4/IPv6 | | `[RequiredZod(…)]` | `RequiredRule` | suffixed; exposes `AllowEmptyStrings`/`TrimWhitespace` and rejects an absent value exactly like `[Required]` | | `[RangeZod(…)]` | `RangeRule` | the type-parameter bounds are doubles; the rule closes with the member type | | `[LengthZod(…)]` | `LengthRule` | suffixed to avoid the DataAnnotations name | | `[StringLengthZod(…)]` | `StringLengthRule` | as above | | `[CompareZod(…)]` | `CompareRule` | as above | | `[AllowedValuesZod(…)]` | `AllowedValuesRule` | a `params object[]` array (and a single-value overload); the rule closes with the member type | | `[DeniedValuesZod(…)]` | `DeniedValuesRule` | a `params object[]` array (and a single-value overload); the rule closes with the member type | A generated attribute's constructor mirrors the rule's value parameters: a parameter the rule declares without a default becomes a required constructor argument, and a parameter with a default keeps that default (so an optional value such as `[StartsWith("…", StringComparison.OrdinalIgnoreCase)]` still has a usable default). The value parameters are also exposed as settable properties. The rule's `message` and identity (`code`/`origin`) parameters stay properties only — `Message` and, where present, `Code` — and default to the rule's own `MessageFormat`/`ErrorCode`, so the attribute is self-describing. Each generated attribute reports the rule's own `ErrorCode`/`Origin` because every built-in rule implements `IZodRule`. Rules whose derived attribute name is already taken by `System.ComponentModel.DataAnnotations` — `MinLengthRule`, `MaxLengthRule`, `UrlRule`, `PhoneRule`, `CreditCardRule`, `Base64StringRule`, `RequiredRule`, `RangeRule`, `LengthRule`, `StringLengthRule`, `CompareRule`, `AllowedValuesRule`, `DeniedValuesRule` — are emitted under a `Zod` suffix instead (for example `[MinLengthZod]`, `[RequiredZod]`, `[RangeZod]`), so the rule's own identity and message stay usable. The generic bound rules surface their type-parameter bound as a `double`, so `[MinValue(3)]` works on an `int` or a `double` member (the value is converted to the member type). ## Exposed as DataAnnotations attributes The `[ZodSchema]` generator maps several built-in rules to their `System.ComponentModel.DataAnnotations` attributes, and any custom rule can be mapped with `[ZodRule(typeof(...))]`: | Attribute | Rule | Failure code | |---|---|---| | `[EmailAddress]` | `EmailRule` | `invalid_string` | | `[Url]` | `UrlRule` | `invalid_string` | | `[Phone]` | `PhoneRule` | `invalid_string` | | `[CreditCard]` | `CreditCardRule` | `invalid_string` | | `[Base64String]` | `Base64StringRule` | `invalid_string` | | `[RegularExpression]` | compiled `Regex`, not `RegexRule` | `invalid_string` | Size and range attributes (`[Length]`, `[StringLength]`, `[MinLength]`, `[MaxLength]`, `[Range]`) are emitted as direct, typed codegen; the equivalent rule structs (`LengthRule`, `StringLengthRule`, `MinLengthRule`, `MaxLengthRule`, `RangeRule`) are also available directly and through their generated attributes. The rule-identity variants of the colliding names are the suffixed [generated attributes](https://github.com/purview-dev/zodsharp/blob/main/docs/wiki/schemas#generated-attributes) (`[MinLengthZod]`, `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`, `[RequiredZod]`, `[RangeZod]`, `[LengthZod]`, `[StringLengthZod]`, `[CompareZod]`, `[AllowedValuesZod]`, `[DeniedValuesZod]`), which report the rule's own `Code` and accept a `Message`. The remaining built-in rules are covered by their own generated attributes — for example `[Regex("…")]` validates through `RegexRule`, whereas `[RegularExpression]` compiles the pattern inline. See [Source Generator DataAnnotations](../../generator/source-generator-dataannotations/) for the full attribute table and the generated metadata, and [Custom Rules](../custom-rules/) for mapping your own rules to attributes. ## Related - [Custom Rules](../custom-rules/) — the `IValidationRule` contract, `IZodRule`, and attribute mapping. - [String Validation](../string-validation/) — the `ZodString` fluent surface and span validation. - [Number Validation](../number-validation/) — the `ZodNumber` fluent surface. - [Source Generator DataAnnotations](../../generator/source-generator-dataannotations/) — attributes the generator understands. - [Source Generator Diagnostics](../../generator/source-generator-diagnostics/) — `ZODSGEN042` and the rule conventions analyzer. --- # Custom Rules A **rule** is a `readonly record struct` (or class) implementing `ZodSharp.Core.IValidationRule`. Rules are evaluated after a schema's type/structural check succeeds; each failing rule adds a `ValidationError` to the result. Custom rules are first class: - attach them to a schema with the public `AddRule`/`Rule` API, or - surface them as a `System.ComponentModel.DataAnnotations`-style attribute (for example `[NoWhitespace]`) that the `[ZodSchema]` source generator honours exactly like `[EmailAddress]` or `[Range]`. ## The rule contract ```csharp namespace ZodSharp.Core; public interface IValidationRule { bool IsValid(in T value); string GetErrorMessage(in T value); } ``` Implementations should be structs so validation does not allocate. `IsValid` is called only when the surrounding schema succeeded and (for nullable properties) the value is not `null`. For a **string** rule, also implement `ZodSharp.Core.IStringValidationRule` (`bool IsValid(ReadOnlySpan value)` / `string GetErrorMessage(ReadOnlySpan value)`) so the rule participates in `ZodString.ValidateSpan`/`IsValidSpan` without materialising the input. Rules that only implement `IValidationRule` are still fully supported; they simply fall back to the string pipeline for span validation. ## Error code and message definitions Every rule **should** expose its error identity as public constants so code (and tests) can assert against the rule rather than re-typing literals: ```csharp public readonly record struct EmailRule : IValidationRule { public const string ErrorCode = "invalid_string"; public const string MessageFormat = "Invalid email format"; public string Code => ErrorCode; public string GetErrorMessage(in string value) => RuleMessage.Format(MessageFormat, value); } ``` - **`ErrorCode`** is the rule's canonical code — the value a test compares against when no per-usage override is supplied. The interface member `IValidationRule.Code` defaults to `"validation_failed"`; a rule that accepts a per-usage `code` override returns it (falling back to `ErrorCode`) from both `Code` and `IZodRule.Code`, so every route reports the same effective value. - **`MessageFormat`** is a positional template. `{0}` (and `{1}`, …) are the offending value and any rule-specific arguments. Format it with `ZodSharp.Rules.RuleMessage.Format(format, …)`, which uses the invariant culture (messages are culture-independent; hosts localize at the boundary - see [Localization](../../hosting/localization/)), caches the parsed format and **never throws**: a custom message with a stray brace or an index you do not supply is written out literally instead of raising `FormatException` at validation time. - **Keep the input out of the default message.** Errors leave the process (ProblemDetails, logs, UI), so a default `MessageFormat` should describe what was expected rather than quote the value (`"Invalid email format"`, not `"Invalid email format: {0}"`). The built-in rules follow this; see [Core Concepts: PII](../../concepts/core-concepts/#pii). - The constants may be inherited from a base rule class, and **abstract bases are exempt**, so a shared base can host them for its concrete derivations. The convention is enforced by an analyzer: a source-declared rule that does not expose a public `const string ErrorCode` **and** a public `const string MessageFormat` is reported as **ZODSGEN042**. See [Source Generator Diagnostics](../../generator/source-generator-diagnostics/) for the full list. A `[Test]` can therefore assert without duplicating strings: ```csharp await Assert.That(error.Code).IsEqualTo(EmailRule.ErrorCode); await Assert.That(error.Message).IsEqualTo(EmailRule.MessageFormat); ``` ### Runtime identity: `IZodRule` The constants are the rule's *static* default. When one attribute must produce a different code per annotated member, the rule implements `ZodSharp.Core.IZodRule` and supplies its own code/origin at runtime: ```csharp public interface IZodRule { string? Code { get; } // null falls back to the attribute-mapped code string? Origin { get; } // null falls back to the attribute-mapped origin // Optional structured identity (default interface members returning null): string? Category => null; // the Zod issue code (ZodIssueCategories) ErrorBound? Lower => null; // the bounds the rule enforces ErrorBound? Upper => null; string? MessageFormat => null; // a named-placeholder template hosts may re-format IReadOnlyDictionary? Parameters => null; // e.g. format, pattern, divisor } ``` `ZodRuleErrors` (`ZodSharp.Core`) copies every member onto the reported `ValidationError`, so a runtime schema and a generated validator report exactly the same error for the same rule. Every built-in rule implements the structured members. The generated code casts to `IZodRule` when reading the values, so the identity can depend on the rule's constructor arguments. A rule that accepts a `code`/`origin` constructor parameter **without** implementing `IZodRule` never reaches the reported error identity and is reported as **ZODSGEN039**. The reported code and origin are resolved from the rule (when it implements `IZodRule`), then the applied attribute, then the `[ZodRule(typeof(...))]` mapping, and finally default to `validation_failed` — see [Error identity: code and origin precedence](https://github.com/purview-dev/zodsharp/blob/main/docs/wiki/schemas#error-identity-code-and-origin-precedence) for the full order. ### Value-dependent failures: `IZodFailureDescriber` Some failures depend on the value: a range rule is `too_small` below the range and `too_big` above it, and a length rule reports the length it saw. Implement `IZodFailureDescriber` (or `IZodStringFailureDescriber` for the span path) to describe that part; it is called only after the rule rejected the value, and its result is merged over the rule's static identity: ```csharp public readonly record struct BetweenRule(int Min, int Max) : IValidationRule, IZodRule, IZodFailureDescriber { public const string ErrorCode = "out_of_range"; public const string MessageFormat = "Value must be between {0} and {1}"; public bool IsValid(in int value) => value >= Min && value <= Max; public string GetErrorMessage(in int value) => RuleMessage.Format(MessageFormat, Min, Max); public string Code => ErrorCode; string? IZodRule.Code => ErrorCode; string? IZodRule.Origin => ZodTypeNames.Number; ErrorBound? IZodRule.Lower => ErrorBound.Including(Min); ErrorBound? IZodRule.Upper => ErrorBound.Including(Max); public ZodRuleFailure DescribeFailure(in int value) => new(Category: value < Min ? ZodIssueCategories.TooSmall : ZodIssueCategories.TooBig); } ``` `ZodRuleFailure(string? Category = null, IReadOnlyDictionary? Parameters = null)`: a `null` category keeps `IZodRule.Category`, and the parameters are merged over `IZodRule.Parameters`. When `ZodErrorOptions.IncludeReceivedValues` is enabled (development only), `ZodRuleErrors` also adds the raw input as the `received` parameter. ### Message overrides The reported message is resolved in this order: 1. A `message` argument on the rule (typically a `Message` constructor parameter), when set. 2. The attribute's `ErrorMessage` / `ErrorMessageResourceName` / `ErrorMessageResourceType`, mapped to a constructor parameter named `message`. 3. `GetErrorMessage(value)`, which formats `MessageFormat` with the offending value. `MessageFormat` is therefore the fallback, not the only message. ## Non-sentinel values (EF-friendly) `ZodSharp.Rules.NonSentinelRule` rejects the framework default/boundary values an ORM commonly stores to represent "no value" — `Guid.Empty`, `DateTime.MinValue`/`MaxValue`, `DateTimeOffset.MinValue`/`MaxValue`, `DateOnly.MinValue`/`MaxValue`, `TimeOnly.MinValue`/`MaxValue`, and `null`/empty/whitespace strings: ```csharp var schema = Z.Date().AddRule(new NonSentinelRule()); var result = schema.Validate(DateTime.MinValue); // result.Errors[0].Code == NonSentinelRule.ErrorCode ("invalid_value") ``` Close it with the property type to use it through an attribute (`[ZodRule(typeof(NonSentinelRule<>))]` on a matching `NonSentinelAttribute`). Types without a known sentinel always pass, so the rule never rejects a type it does not understand. ## Defining a custom rule ```csharp using ZodSharp.Core; using ZodSharp.Rules; namespace MyRules; /// Rejects strings that contain whitespace. public readonly record struct NoWhitespaceRule(string? Message = null) : IValidationRule { public const string ErrorCode = "invalid_string"; public const string MessageFormat = "Whitespace is not allowed."; public bool IsValid(in string value) { if (value is null) return false; foreach (var character in value) { if (char.IsWhiteSpace(character)) return false; } return true; } public string Code => ErrorCode; public string GetErrorMessage(in string value) => Message ?? RuleMessage.Format(MessageFormat, value); } ``` Every rule **should** declare the public `ErrorCode`/`MessageFormat` constants (see [Error code and message definitions](https://github.com/purview-dev/zodsharp/blob/main/docs/wiki/schemas#error-code-and-message-definitions)); omitting them is reported as `ZODSGEN042`, and a rule without them reports the fallback code `validation_failed` when it does not also implement `IZodRule`. The rule can be used standalone: ```csharp var rule = new NoWhitespaceRule(); if (!rule.IsValid("John Doe")) Console.WriteLine(rule.GetErrorMessage("John Doe")); ``` ## Attaching a rule to a schema `ZodType.AddRule(IValidationRule)` and the generic `Rule(TRule)` helper are public, so a custom rule can be composed directly: ```csharp using ZodSharp; using MyRules; var schema = Z.String().Rule(new NoWhitespaceRule("No spaces allowed.")); var result = schema.Validate("John Doe"); // result.IsSuccess == false // result.Errors[0].Code == NoWhitespaceRule.ErrorCode ("invalid_string") // result.Errors[0].Message == "No spaces allowed." // result.Errors[0].Path is empty ``` Both methods mutate the receiver and return it for chaining; see [Guarantees and Limitations](../../quality/guarantees-and-limitations/#fluent-rule-methods-mutate-the-receiver). ## Extending the fluent interface Every built-in rule has a dedicated method on its schema — `Z.String().Email()`, `Z.Number().Min(...)`, `Z.Date().NonSentinel()`, and so on. A custom rule gets the same ergonomics with an **extension method** that adds the rule to the receiver and returns it: ```csharp using ZodSharp.Schemas; namespace MyRules; public static class ZodStringRuleExtensions { /// Rejects strings that contain whitespace. public static ZodString NoWhitespace(this ZodString schema, string? message = null) { schema.AddRule(new NoWhitespaceRule(message)); return schema; } } ``` The call site then mirrors the built-in surface and stays chainable: ```csharp var schema = Z.String().Min(3).NoWhitespace().ToUpper(); ``` Two details keep the chain intact: - **Return the concrete schema type** (`ZodString`, `ZodDate`, …), not the base `ZodType`. The generic `Rule` helper and `AddRule` return the base type, so write `schema.Rule(new MyRule()); return schema;` (or call `AddRule` and return `schema`) rather than returning the result of `Rule(...)` directly. - **Target the schema whose output type the rule validates.** A rule implementing `IValidationRule` extends `ZodString`; a rule implementing `IValidationRule` extends whichever schema produces a `Guid`. For a rule that applies to *any* schema, extend the single-type-argument base `ZodType`: ```csharp using ZodSharp.Core; public static class ZodTypeRuleExtensions { /// Rejects the default value of the schema's output type. public static ZodType NotEmpty(this ZodType schema, string? message = null) where T : struct, IEquatable { schema.AddRule(new NotEmptyRule(Message: message)); return schema; } } ``` A fluent extension only covers the runtime API. To make the same rule usable from `[ZodSchema]` models, map it to an attribute with `[ZodRule]` (see below). The built-in catalogue — including each rule's `ErrorCode` and `MessageFormat` — is in [Validation Rules Reference](../validation-rules-reference/). ## Exposing a rule as a DataAnnotations attribute Built-in rules map to `System.ComponentModel.DataAnnotations` attributes (`EmailRule` ↔ `[EmailAddress]`). A custom rule gets the same treatment in two steps: 1. Author a `ValidationAttribute` whose properties mirror the rule's constructor parameters. 2. Map it to the rule with `[ZodRule(typeof(...))]`. ```csharp using System; using System.ComponentModel.DataAnnotations; using ZodSharp.Core; using MyRules; [ZodRule(typeof(NoWhitespaceRule), Code = "invalid_string", Origin = "string")] [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] public sealed class NoWhitespaceAttribute : ValidationAttribute { /// Overrides the rule's default error message. public string? Message { get; set; } } ``` Apply it to a `[ZodSchema]` model like any other annotation: ```csharp using System.ComponentModel.DataAnnotations; using ZodSharp; [ZodSchema] public class User { [Required] [NoWhitespace(Message = "No spaces allowed.")] public string Name { get; set; } = string.Empty; } ``` The generator emits rule-based validation, so `UserSchema.Validate(user)` fails for `"John Doe"` with: ```text Code = "invalid_string" Message = "No spaces allowed." Origin = "string" Path = ["Name"] ``` Because the attribute derives from `ValidationAttribute`, the property participates in the same "carries a data annotation" discovery as the built-in attributes. The default error code is `validation_failed` when `Code` is not set. :::note The attribute's arguments are mapped to the rule's public constructor parameters: named arguments by name (case-insensitive), and positional arguments by the applied attribute's own constructor parameter names first (with the raw position as a fallback for hand-authored attributes whose parameter names differ from the rule's). A parameter named `message` is supplied from the attribute's `ErrorMessage` when one is set. ::: ### Error identity: code and origin precedence One attribute type can serve many members that each need a different error code. The generator resolves `Code`/`Origin` in this order (first match wins): 1. **Rule-owned** — the rule implements `ZodSharp.Core.IZodRule`, so `IZodRule.Code`/`IZodRule.Origin` are used at runtime (the mapped values are only a fallback when the rule returns `null`). 2. **Attribute-declared** — a `Code` / `Origin` named argument on the applied attribute (for example `[NoWhitespace(Code = "invalid_asset_id")]`). 3. **Attribute-type mapping** — `[ZodRule(typeof(X), Code = "…", Origin = "…")]`. 4. **Default** — `validation_failed` with no origin. ```csharp using ZodSharp.Core; public readonly record struct NotEmptyRule(string? Code = null, string? Message = null) : IValidationRule, IZodRule where T : struct, IEquatable { public const string ErrorCode = "invalid_value"; public const string MessageFormat = "Value must not be empty."; public bool IsValid(in T value) => !value.Equals(default(T)); public string GetErrorMessage(in T value) => Message ?? string.Format(System.Globalization.CultureInfo.InvariantCulture, MessageFormat); // The rule owns its identity, so callers can pass a per-member error code. string? IZodRule.Code => Code; string? IZodRule.Origin => "value_object"; } ``` ### Declaring the identity contract A hand-authored attribute supplies those named arguments by exposing `Code` / `Origin` properties. Implement `ZodSharp.Core.IZodRuleAttribute` to make that contract explicit — the interface requires both members, so the compiler guarantees the properties the generator reads are present, and any member added to the interface is treated as identity rather than as an unconsumed argument: ```csharp public sealed class NoWhitespaceAttribute : ValidationAttribute, IZodRuleAttribute { public string? Code { get; set; } public string? Origin { get; set; } } ``` Attributes that do not implement the interface are still read through the documented `Code` / `Origin` names, so existing declarations keep working. Two warnings keep the identity honest rather than silently dropped: | Situation | Diagnostic | |---|---| | A rule accepts a `code`/`origin` constructor parameter but does not implement `IZodRule`, so the generated validation supplies the value and never reads it back. | `ZODSGEN039` | | An attribute supplies an argument the resolved rule never consumes: no matching constructor parameter, and not part of the error identity (`Code`/`Origin`/`IZodRuleAttribute` members) or the inherited `ErrorMessage`/`ErrorMessageResourceName`/`ErrorMessageResourceType` members. | `ZODSGEN040` | ## Generic rules Map an **unbound generic** rule type and the generator closes it with the property type, so one rule serves every underlying primitive: ```csharp [ZodRule(typeof(NotEmptyRule<>))] [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] public sealed class NotEmptyAttribute : ValidationAttribute { public string? Code { get; set; } public string? Message { get; set; } } ``` - `[NotEmpty]` on a `Guid` property instantiates `NotEmptyRule`; on an `int` property it instantiates `NotEmptyRule`. - The rule must expose exactly one type parameter. A type argument that cannot satisfy the rule's constraints (for example `NotEmptyRule where T : struct` applied to a `string`) is reported as `ZODSGEN030` and no rule is emitted, so the generated code always compiles. ### Rule families: one attribute for a primitive and a scalar value object A constraint can also be *self-referential* (`where TSelf : IScalarValueObject`), which a primitive can never satisfy — `string` does not implement `IScalarValueObject`. Declare both halves of the rule side by side and the generator resolves the member that fits the annotated type: ```csharp namespace MyRules; // Member-level half: validates the underlying primitive. public readonly record struct NonWhiteSpaceStringRule(string? Message = null) : IValidationRule { public const string ErrorCode = "invalid_string"; public const string MessageFormat = "Value must not be empty."; public bool IsValid(in string? value) => value != null && !string.IsNullOrWhiteSpace(value); public string GetErrorMessage(in string? value) => Message ?? string.Format(System.Globalization.CultureInfo.InvariantCulture, MessageFormat); } // Value-object half: validates the scalar as a unit. public readonly record struct NonWhiteSpaceStringRule(string? Code = null, string? Message = null) : IValidationRule, IZodRule where TSelf : IScalarValueObject { public const string ErrorCode = "invalid_string"; public const string MessageFormat = "Value must not be empty."; public bool IsValid(in TSelf value) => value.Value != null && !string.IsNullOrWhiteSpace(value.Value); public string GetErrorMessage(in TSelf value) => Message ?? string.Format(System.Globalization.CultureInfo.InvariantCulture, MessageFormat); string? IZodRule.Code => Code; string? IZodRule.Origin => "value_object"; } [ZodRule(typeof(NonWhiteSpaceStringRule<>))] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] public sealed class NonWhiteSpaceStringAttribute : ValidationAttribute { public string? Code { get; set; } public string? Message { get; set; } } ``` - `[NonWhiteSpaceString]` on a `string`/`string?` member resolves to `NonWhiteSpaceStringRule` (the non-generic sibling). - `[NonWhiteSpaceString]` on a scalar type resolves to `NonWhiteSpaceStringRule`. - Resolution is symmetric: mapping the attribute to the *non-generic* rule still resolves the generic member for a scalar target. - When no member of the family can validate the target type, `ZODSGEN030` is reported and nothing is emitted. ## Generating the attribute from the rule If you do not want to hand-write the attribute, mark the rule itself with the parameterless `[ZodRule]` and the generator emits a matching attribute: ```csharp using ZodSharp.Core; namespace MyRules; [ZodRule(Code = "invalid_string", Origin = "string")] public readonly record struct NoWhitespaceRule(bool AllowEmpty = true, string? Message = null) : IValidationRule { public const string ErrorCode = "invalid_string"; public const string MessageFormat = "Whitespace is not allowed."; public bool IsValid(in string value) => AllowEmpty || value.IndexOf(' ') < 0; public string GetErrorMessage(in string value) => Message ?? string.Format(System.Globalization.CultureInfo.InvariantCulture, MessageFormat); } ``` This produces a `NoWhitespaceAttribute` in the rule's namespace, shaped like: ```csharp /// Validation attribute that applies NoWhitespaceRule. [global::System.AttributeUsage( global::System.AttributeTargets.Class | global::System.AttributeTargets.Struct | global::System.AttributeTargets.Property | global::System.AttributeTargets.Field | global::System.AttributeTargets.Parameter, Inherited = true, AllowMultiple = false)] [global::ZodSharp.Core.ZodRule(typeof(global::MyRules.NoWhitespaceRule), Code = "invalid_string", Origin = "string")] public sealed class NoWhitespaceAttribute : global::System.ComponentModel.DataAnnotations.ValidationAttribute { /// Initializes the attribute with the values required by NoWhitespaceRule. public NoWhitespaceAttribute(bool allowEmpty = true) { AllowEmpty = allowEmpty; } public bool AllowEmpty { get; set; } = true; public string? Message { get; set; } = null; } ``` Mapping rules: - The attribute name is the rule name with a trailing `Rule` replaced by `Attribute` (`NoWhitespaceRule` → `NoWhitespaceAttribute`). Override it with `[ZodRule(AttributeName = "…")]`. - Each public constructor parameter becomes a settable property, Pascal-cased, with the parameter's default value preserved. The rule's **value** parameters are additionally emitted as a constructor: a parameter the rule declares without a default becomes a required constructor argument, so an attribute like `[MinValue]` cannot be applied without its bound, while a parameter with a default keeps that default (so `[NoWhitespace]` still works). A rule that overloads its constructor surfaces one attribute constructor per attribute-addressable overload, and the applied constructor selects the matching rule overload — `UUIDRule` yields both `[UUID]` (the versionless overload) and `[UUID(UuidVersion.V4)]` (the versioned one). A parameter named `message` becomes a `Message` property (the resolver maps it onto the rule's `message` argument) and defaults to the rule's `MessageFormat`; a parameter named `code`/`origin` becomes a `Code`/`Origin` property and `Code` defaults to the rule's `ErrorCode`; the inherited `ValidationAttribute.ErrorMessage` remains the fallback. A type-parameter parameter (for example the bound of `MinValueRule`) is surfaced as a `double`. - If the derived name collides with a `System.ComponentModel.DataAnnotations` attribute, the generated attribute is emitted under a `Zod` suffix (`MinLengthAttribute` → `MinLengthZodAttribute`, used as `[MinLengthZod]`). - The attribute is always decorated with `AttributeTargets.Class | Struct | Property | Field | Parameter`, so it can annotate a member or a scalar value object. (`Class`/`Struct` are what make the [type-level form](https://github.com/purview-dev/zodsharp/blob/main/docs/wiki/schemas#type-level-rules) possible.) - The rule must be non-nested and non-abstract, and every parameter type must be a legal attribute-argument type (primitive, `string`, `enum`, `System.Type`). - An **arity-1 generic rule** can be marked as well: the generated attribute maps to the open generic (`[ZodRule(typeof(NonWhiteSpaceStringRule<>))]`), which is the form that serves both a primitive member and a scalar value object. Rules with two or more type parameters are rejected (`ZODSGEN032`). - When both halves of a [rule family](https://github.com/purview-dev/zodsharp/blob/main/docs/wiki/schemas#rule-families-one-attribute-for-a-primitive-and-a-scalar-value-object) are marked, only the generic half emits the attribute; the two mappings would otherwise claim the same name. - If a hand-authored type already declares the derived name, the generated attribute is suppressed and reported as `ZODSGEN037`. The hand-authored declaration's own `[ZodRule]` mapping then governs every usage of that attribute name, so confirm it still matches what the call sites expect. - `[ZodRule(AllowMultiple = true)]` emits `AttributeUsage(..., AllowMultiple = true)`, so the attribute may be applied to a member more than once. Each application becomes its own rule, configured from that application's arguments and evaluated in source order: ```csharp [ZodRule(AllowMultiple = true)] public readonly record struct MultipleOfRule(int Factor = 1, string? Message = null) : IValidationRule { public const string ErrorCode = "not_multiple_of"; public const string MessageFormat = "Number must be a multiple of {0}"; public bool IsValid(in int value) => Factor != 0 && value % Factor == 0; public string GetErrorMessage(in int value) => Message ?? string.Format(System.Globalization.CultureInfo.InvariantCulture, MessageFormat, Factor, value); } [ZodSchema] public partial class Sample { [MultipleOf(Factor = 3)] [MultipleOf(Factor = 5)] public int Value { get; set; } // must be a multiple of both 3 and 5 } ``` - A **hand-authored** attribute whose name encodes a rule name (`XAttribute` → `XRule`) must map to a rule that addresses every rule declared under that name. A mapping that declares only a non-generic rule while an arity-1 generic sibling exists (or that declares an unrelated rule) is reported as `ZODSGEN038`, because some usages of the attribute would resolve to no rule. An attribute name that does not encode a declared rule family is left alone, so free-form names remain valid. :::caution The generated attribute lives in the same assembly as the rule, but Roslyn generators cannot read another generator's output as a symbol. To *consume* a generated attribute with `[ZodSchema]`, reference the rule from a separate assembly (a rules library) — or hand-author the attribute and mark it with `[ZodRule(typeof(...))]`. The built-in rules below already satisfy this: their attributes ship inside `Purview.ZodSharp`. ::: ## Built-in attributes (shipped with Purview.ZodSharp) Every built-in rule that can be expressed as an attribute is generated once into the `Purview.ZodSharp` assembly, in the `ZodSharp.Rules` namespace, so a consumer can annotate a member or a scalar without hand-authoring anything: ```csharp using ZodSharp; using ZodSharp.Rules; [ZodSchema] public partial class Contact { [Email] public string Email { get; set; } = string.Empty; [E164] public string Phone { get; set; } = string.Empty; [Regex("^[a-z]+$")] public string Code { get; set; } = string.Empty; [NonSentinel(Message = "Id must not be the default.")] public Guid Id { get; set; } } ``` Each attribute mirrors its rule's constructor parameters and reports the rule's own `ErrorCode`/`Origin` (every built-in rule implements `IZodRule`). A value the rule declares without a default is a **required constructor argument** — `[Regex("^[a-z]+$")]`, `[UUID(UuidVersion.V4)]`, `[MinValue(3)]` — so it can never be silently omitted; a value with a default keeps it (for example `[StartsWith("https://", StringComparison.OrdinalIgnoreCase)]`). A rule with overloaded constructors mirrors each overload, so `[UUID]` uses the versionless overload and `[UUID(UuidVersion.V4)]` (or `[UUID(Version = UuidVersion.V4)]`) the versioned one. `[Regex]` uses the `(string pattern, string? message)` overload, so `Pattern` is a string. A rule's `message` parameter is surfaced as a `Message` property (defaulting to the rule's `MessageFormat`) and its `code` parameter (where present) as a `Code` property (defaulting to the rule's `ErrorCode`). A type-parameter value is surfaced as a `double`; an array of type parameters (for example the allowed values of `AllowedValuesRule`) is surfaced as a `params object[]`, with each element converted back to the member type at the usage site, so `[AllowedValuesZod("a", "b", "c")]` works on a `string` member and `[DeniedValuesZod(1, 2, 3)]` on an `int` member. Two adjustments keep every rule addressable: - **Name collisions.** `MinLengthRule`, `MaxLengthRule`, `UrlRule`, `PhoneRule`, `CreditCardRule`, `Base64StringRule`, `RequiredRule`, `RangeRule`, `LengthRule`, `StringLengthRule`, `CompareRule`, `AllowedValuesRule`, and `DeniedValuesRule` derive an attribute name that `System.ComponentModel.DataAnnotations` already uses. Their attributes are emitted under a `Zod` suffix instead — `[MinLengthZod]`, `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`, `[RequiredZod]`, `[RangeZod]`, `[LengthZod]`, `[StringLengthZod]`, `[CompareZod]`, `[AllowedValuesZod]`, `[DeniedValuesZod]` — so the rule's own `Code`/`Message` stay usable alongside the DataAnnotations attribute. - **Generic bounds.** The generic bound rules (`MinValueRule`, `MaxValueRule`, `GreaterThanRule`, `LessThanRule`, `GreaterThanOrEqualRule`, `LessThanOrEqualRule`) surface their type-parameter bound as a `double`, so `[MinValue(3)]` works on an `int` or a `double` member (the value is converted to the member type). The numeric parity and inclusive-comparison rules follow the same pattern: `[GreaterThanOrEqual(…)]`, `[LessThanOrEqual(…)]`, `[Even]`, and `[Odd]`. `[Enum]` mirrors `EnumRule` and closes the open generic with the annotated enum member type. The `[ZodSchema]` generator applies the rule automatically to enum properties; `[ZodIgnore]` on an enum member excludes that member from the rule everywhere the enum is validated, and a property's `[DeniedValues]` excludes values for that property only. See [Enum rules](../validation-rules-reference/#enum-rules). ## Type-level rules Rules can also be attached to the **`[ZodSchema]` type itself** instead of a property. They validate the whole value (the value object as a unit) and report an **empty path**, which is what you want for a scalar whose single `Value` *is* the value: ```csharp [ZodRule(typeof(NotEmptyRule<>))] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] public sealed class NotEmptyAttribute : ValidationAttribute { public string? Code { get; set; } public string? Message { get; set; } } [NotEmpty(Code = "invalid_asset_id", Message = "AssetId must not be empty.")] [ZodSchema] public partial record struct AssetId { public Guid Value { get; init; } } ``` The generator emits the rule against the value itself — no property local, and `EmptyPath` rather than a property path: ```csharp var assetIdCustomRule0 = new global::MyRules.NotEmptyRule("invalid_asset_id", "AssetId must not be empty."); if (!assetIdCustomRule0.IsValid(value)) { (errors ??= new List()).Add( ValidationError.Create( ((global::ZodSharp.Core.IZodRule)assetIdCustomRule0).Code ?? "invalid_asset_id", assetIdCustomRule0.GetErrorMessage(value), EmptyPath, origin: ((global::ZodSharp.Core.IZodRule)assetIdCustomRule0).Origin)); } ``` - **Generic closure:** a type-level attribute closes an unbound generic rule with the **target type** (`NotEmptyRule`), so the rule sees the value object and can read its state through its own constraints. On a `[Scalar]` type, a rule written against the underlying value is instead closed with that value and wrapped — see [Value Objects Integration](../value-objects-integration/). - **Ordering** in the generated `Validate`: property rules → **type-level rules** → the synchronous `Validate()` refinement. - Type-level attributes need `AttributeTargets.Class`/`Struct` on the attribute declaration; the property-level attributes above only need `Property`/`Field`. ### When a type-level rule does not run - **The type gets no schema.** The generator is driven by `[ZodSchema]` and then walks nested complex property types. A rule attribute on a type that gets no schema is **ignored**, and the analyzer reports the warning **`ZODSGEN033`** so the mistake is visible. A type without `[ZodSchema]` that is referenced as a complex property of a schema *does* get a (secondary) schema, so its type-level rules run and no warning is raised. - **`[ZodSchema(GenerateValidateMethod = false)]`.** Type-level rules live inside `Validate`, so they are omitted along with it. - **`DisableZodSharpSourceGenerator`.** The generator — and therefore every rule — is skipped. ## Validating scalar value objects A `Purview.ValueObjects` scalar **is** a single value, so validate it as a unit rather than through its `Value` property. Attach a rule at the **type level** (on the scalar type, not on its `Value` property) and the reported error has an **empty path**: ```csharp [Scalar] [ZodSchema] [NotEmpty(Code = "invalid_asset_id", Message = "AssetId must not be empty.")] public readonly partial record struct AssetId { public Guid Value { get; init; } } ``` ```text Code = "invalid_asset_id" Message = "AssetId must not be empty." Origin = "value_object" Path = [] ``` A rule can be written against the value object itself (`where TSelf : IScalarValueObject`), or against the underlying value (`IValidationRule`) and adapted automatically by the value-objects generator's `ScalarRuleAdapter`. :::caution `Purview.ValueObjects.ScalarRuleAdapter` is emitted into your compilation by the **Purview.ValueObjects** source generator whenever the project references both `Purview.ValueObjects` and `Purview.ZodSharp`. Do not declare it yourself. If the value-objects generator is disabled (`DisableValueObjectsSourceGenerator`), the adapter is not emitted and the generated validator will not compile. ::: :::note If the type has no value-object contract (a plain class with a `Guid` property), the property-level form still works: map the attribute to an `IValidationRule` and put it on `Value`. See [Exposing a rule as a DataAnnotations attribute](https://github.com/purview-dev/zodsharp/blob/main/docs/wiki/schemas#exposing-a-rule-as-a-dataannotations-attribute) and [Generic rules](https://github.com/purview-dev/zodsharp/blob/main/docs/wiki/schemas#generic-rules). ::: :::tip If the non-empty policy should be implicit rather than an attribute, the value-objects layer is the natural place to emit the attribute on the scalar type (it already knows about ZodSharp through `ZodSchemaMode`). ::: See [Value Objects Integration](../value-objects-integration/) for the full walkthrough: wiring `[Scalar]` + `[ZodSchema]`, both rule shapes, the generated code, the error code / message definitions, and testing. ## Diagnostics | ID | Severity | Meaning | |---|---|---| | ZODSGEN030 | Error | The mapped rule does not implement `IValidationRule` for the property type (or the rule target type), or an unbound generic rule could not be closed with it. | | ZODSGEN031 | Error | A rule constructor parameter could not be mapped from the attribute. | | ZODSGEN032 | Error | A validation attribute could not be generated for the rule. | | ZODSGEN033 | Warning | A rule-mapped attribute is applied to a type that gets no generated schema (no `[ZodSchema]` and not referenced as a complex property), so the rule never runs. | | ZODSGEN037 | Warning | A rule marked `[ZodRule]` derives an attribute name that is already declared by hand, so the generated attribute is suppressed and the hand-authored declaration's own `[ZodRule]` mapping governs every usage. | | ZODSGEN038 | Warning | A hand-authored rule attribute's `[ZodRule(typeof(...))]` mapping does not address every rule declared under the name the attribute encodes (`XAttribute` → `XRule`), leaving some usages of the attribute unresolved. | | ZODSGEN039 | Warning | A rule accepts a `code`/`origin` constructor parameter without implementing `IZodRule`, so the value never reaches the reported error identity. | | ZODSGEN040 | Warning | An attribute argument has no effect: the resolved rule has no matching constructor parameter and the value is not part of the reported error identity. | | ZODSGEN042 | Warning | A validation rule does not expose public `const string ErrorCode` / `MessageFormat` constants, so its error identity cannot be asserted in tests without duplicating literals. | | ZODSGEN043 | Info | A built-in rule marked `[ZodRule]` does not generate a validation attribute (its name is taken by a `System.ComponentModel.DataAnnotations` attribute, or a constructor parameter cannot be represented as an attribute property). | See [Source Generator Diagnostics](../../generator/source-generator-diagnostics/) for the full list. ## Related - [Validation Rules Reference](../validation-rules-reference/) — the catalogue of built-in rules. - [Fluent Schema API](../../fluent-schema-api/) — `AddRule`/`Rule` live on `ZodType`. - [Value Objects Integration](../value-objects-integration/) — `[Scalar]` value objects and the `ScalarRuleAdapter`. - [Source Generator DataAnnotations](../../generator/source-generator-dataannotations/) — built-in attribute coverage. - [Guarantees and Limitations](../../quality/guarantees-and-limitations/) — allocation and mutation semantics. --- # Value Objects Integration A [Purview.ValueObjects](https://www.nuget.org/packages/Purview.ValueObjects) **scalar** is a strongly typed wrapper around a single value (`AssetId` around a `Guid`, `TenantId` around a `Guid`, `EmailAddress` around a `string`, and so on). Because a scalar *is* one value, ZodSharp validates it as a unit — not through its `Value` property — and reports the failure with an **empty path**. This page shows how to wire the `[Scalar]` generation to the `[ZodSchema]` generation, the two shapes a custom rule can take, and how the rule's error code and message are defined. For the general rule contract, see [Custom Rules](../custom-rules/). ## Prerequisites and wiring Reference both packages: ```bash dotnet add package Purview.ValueObjects dotnet add package Purview.ZodSharp ``` A scalar is declared with the `[Scalar]` attribute (from `Purview.ValueObjects.Serialization`) and given a ZodSharp schema with `[ZodSchema]`. The type must be `partial`: ```csharp using Purview.ValueObjects.Serialization; using ZodSharp; namespace ChangeOps; [Scalar] [ZodSchema] public readonly partial record struct AssetId { public Guid Value { get; init; } } ``` Each attribute brings in a different generator: | Attribute | Generator | What it emits | |---|---|---| | `[Scalar]` | **Purview.ValueObjects** | the `IScalarValueObject` implementation (and, when `Purview.ZodSharp` is referenced, the `ScalarRuleAdapter` used below) | | `[ZodSchema]` | **Purview.ZodSharp** | the static `AssetIdSchema` validator (`Validate`/`Parse`, and so on) | The underlying member defaults to a public property named `Value`. Rename it with the attribute argument and the value-objects generator follows: ```csharp [Scalar("Id")] [ZodSchema] public readonly partial record struct TenantId { public Guid Id { get; init; } } ``` Scalars implement the two-type-parameter contract: ```csharp namespace Purview.ValueObjects; public interface IScalarValueObject : IValueObject, IComparable, IComparable where TSelf : IScalarValueObject { TValue Value { get; } static abstract TSelf Create(TValue value); static abstract TSelf Hydrate(TValue value); int CompareTo(TValue other); } ``` so `AssetId` is `IScalarValueObject`. :::caution The ZodSharp generator discovers a scalar through the source-visible `[Scalar]` attribute and the public property it names — it does not depend on the `IScalarValueObject` interface, because that interface is contributed by another generator and may not be visible as a symbol in the same compilation pass. ::: :::note The `ScalarRuleAdapter` type is emitted into your compilation by the **Purview.ValueObjects** generator whenever the project references both `Purview.ValueObjects` and `Purview.ZodSharp`. Do not declare it yourself. If the value-objects generator is disabled (`DisableValueObjectsSourceGenerator`), the adapter is not emitted and the generated validator will not compile. ::: ## Automatic scalar forms `Purview.ValueObjects` 1.0.1 (or later) also supports an **automatic** form, where the value-object generator declares the underlying property for you. Both spellings are equivalent: ```csharp [Scalar] [Scalar(typeof(Guid))] [Scalar(Nullable = true)] [Scalar(typeof(string), Nullable = true)] [Scalar] [Scalar(typeof(int?))] [Scalar("Id")] [Scalar(typeof(Guid), "Id")] ``` A nullable reference type cannot be a generic attribute argument (`[Scalar]` reports `CS8970`) or a `typeof` operand (`typeof(string?)` reports `CS8639`), so a nullable reference scalar is written with `Nullable = true`. For a value type, `Nullable = true` wraps the underlying type in `Nullable`, so `[Scalar(Nullable = true)]` emits `public DateTimeOffset? Value`. Nullable value types can also be written directly (`[Scalar]` / `[Scalar(typeof(int?))]`). A nullable scalar round-trips JSON `null`, so its schema accepts `null`. When the underlying value is nullable, a type-level rule written against that value is applied only when a value is present. The rule is closed over the non-nullable value type (so constrained rules such as `MinValueRule` work), and `null` is treated as absent. Use `[RequiredZod]` when `null` must itself fail, exactly as for a nullable member. The property is emitted by the value-object generator, which ZodSharp cannot observe, so the automatic form has no source member for ZodSharp to read. ZodSharp resolves the underlying type from the attribute instead, so the schema is generated exactly as for the manual form: ```csharp [Scalar] [ZodSchema] [NonSentinel(Message = "InstallationId must not be empty.")] public readonly partial record struct InstallationId { } ``` Because there is no property to carry DataAnnotations, the automatic form supports **type-level** rules only: - `[RequiredZod]`, `[NullOrNonWhiteSpace]`, `[NonSentinel]`, and any custom `[ZodRule(typeof(...))]`-mapped attribute applied to the type are read from the type and applied to the scalar's underlying value, reporting an **empty path** exactly as for the manual form. For a nullable value scalar the rule runs only when the value is present; for a nullable reference scalar the rule receives the nullable value so it can decide (hence the null-tolerant `[NullOrNonWhiteSpace]`). - A **property-level DataAnnotation has no host** on the generated property, so it is never observed. Use a Zod-native type-level rule, or switch to the manual `[Scalar]` form when property-level DataAnnotations are required. - `[RequiredZod]` always rejects `null`; use `[NullOrNonWhiteSpace]` (or a custom null-tolerant rule) when a nullable scalar must accept `null` while rejecting whitespace. `[ZodSchema(GenerateValidateMethod = false)]` is incompatible with a scalar form, because the value-object generator's generated `Create` always calls `{Type}Schema.Validate(instance)`; ZodSharp reports **`ZODSGEN044`** rather than leaving a dangling reference, and it also reports it when the declared underlying type cannot be represented. ## Two ways to attach a rule to a scalar There are two shapes, and the generator picks the right one from the rule's own constraints: 1. **A rule written against the value object** (`where TSelf : IScalarValueObject`) closes over the scalar itself, so it can read state through the value-object contract. 2. **A rule written against the underlying value** (`IValidationRule`) is **adapted automatically**: the generator reads the scalar's property, closes the rule with the underlying value, and wraps it in `ScalarRuleAdapter` so the rule still runs against the value object as a unit. One rule then serves every scalar backed by the same primitive. In both cases the rule is attached at the **type level** — put the attribute on the scalar type, not on its `Value` property — and the reported error has an **empty path**: ```text Code = "invalid_asset_id" Message = "AssetId must not be empty." Origin = "value_object" Path = [] ``` :::tip `ZodSharp.Rules.NonSentinelRule` and its generated `ZodSharp.Rules.NonSentinelAttribute` ship with `Purview.ZodSharp`, so the canonical non-sentinel case is available as `[NonSentinel(Message = "…")]` on a `[Scalar]` type without defining anything yourself. The walkthroughs below use local rules (`NotEmptyRule`, `MyRules.NonSentinelRule`) to show the full shape. ::: Type-level attributes need `AttributeTargets.Class | AttributeTargets.Struct` (and usually `Property`/`Field` too, so the same attribute can validate a primitive member). A generated attribute already carries all five targets: ```csharp [ZodRule(typeof(NotEmptyRule<>))] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] public sealed class NotEmptyAttribute : ValidationAttribute { public string? Code { get; set; } public string? Message { get; set; } } ``` ## Error code and message definitions Every rule **should** expose its error identity as public constants so consumers and tests can assert against the rule rather than re-typing literals: ```csharp public const string ErrorCode = "invalid_value"; public const string MessageFormat = "Value must not be empty."; ``` - `ErrorCode` is the rule's canonical code — the value a test compares against (`Assert.That(error.Code).IsEqualTo(NotEmptyRule.ErrorCode)`). - `MessageFormat` is a `string.Format` template. `{0}` (and `{1}`, …) are the offending value and any rule-specific arguments; format it with `RuleMessage.Format(MessageFormat, …)` (invariant culture, never throws). - The constants may be inherited from a base rule class, and **abstract bases are exempt**, so a shared base can host them for its concrete derivations. A rule that omits either constant is reported as **ZODSGEN042** (a warning). ### Runtime identity: `IZodRule` The constants are the *static* default. When a single attribute must produce a different code per annotated scalar, the rule implements `ZodSharp.Core.IZodRule` and supplies its own code/origin at runtime: ```csharp public interface IZodRule { string? Code { get; } // null falls back to the attribute-mapped code string? Origin { get; } // null falls back to the attribute-mapped origin } ``` The generated code reads the identity through an `IZodRule` cast, preferring the rule over the mapped attribute values. A rule that accepts a `code`/`origin` constructor parameter **without** implementing `IZodRule` never reaches the reported error identity and is reported as **ZODSGEN039**. The full precedence for the reported code and origin (first match wins): 1. **Rule-owned** — the rule implements `IZodRule`, so its `Code`/`Origin` are used at runtime (the mapped values are only a fallback when the rule returns `null`). 2. **Attribute-declared** — a `Code` / `Origin` named argument on the applied attribute (for example `[NotEmpty(Code = "invalid_asset_id")]`). 3. **Attribute-type mapping** — `[ZodRule(typeof(X), Code = "…", Origin = "…")]`. 4. **Default** — `validation_failed`, with no origin. ### Message overrides The reported message is resolved in this order: 1. A `message` argument on the rule (typically a `Message` constructor parameter), when set. 2. The attribute's `ErrorMessage` / `ErrorMessageResourceName` / `ErrorMessageResourceType`, mapped to a constructor parameter named `message`. 3. `GetErrorMessage(value)`, which formats `MessageFormat` with the offending value. `MessageFormat` is therefore the fallback, not the only message. When the fallback is used by a **type-level** rule on a scalar, the generator rewords it: the leading subject word `Value` in `MessageFormat` is replaced with the scalar's name, so the error still identifies its owner even though it reports an empty path. A rule whose `MessageFormat` does not contain `Value` (for example `"Number must be even, but got {0}"`) is left unchanged, and an explicit `message`/`ErrorMessage` is never rewritten. This is why `NonSentinelRule` reports `TenantId has a sentinel value, but got an empty GUID` for `[NonSentinel]` on a `[Scalar] TenantId`, but `Value has a sentinel value, but got an empty GUID` for a member or the fluent API. ### Rule against the value object Define one generic rule per underlying primitive, close it over the scalar, and implement `IZodRule` so each scalar keeps its own code: ```csharp // MyRules/NotEmptyRule.cs — a rules library that references Purview.ValueObjects using System.Globalization; using ZodSharp.Core; namespace MyRules; public readonly record struct NotEmptyRule(string? Code = null, string? Message = null) : IValidationRule, IZodRule where TSelf : IScalarValueObject { public const string ErrorCode = "invalid_value"; public const string MessageFormat = "Value must not be empty."; public bool IsValid(in TSelf value) => value.Value != Guid.Empty; public string GetErrorMessage(in TSelf value) => Message ?? string.Format(CultureInfo.CurrentCulture, MessageFormat); string? IZodRule.Code => Code; string? IZodRule.Origin => "value_object"; } ``` ```csharp // Purview.ChangeOps using MyRules; using Purview.ValueObjects.Serialization; using ZodSharp; [Scalar] [ZodSchema] [NotEmpty(Code = "invalid_asset_id", Message = "AssetId must not be empty.")] public readonly partial record struct AssetId { public Guid Value { get; init; } } [Scalar] [ZodSchema] [NotEmpty(Code = "invalid_external_identity_id", Message = "ExternalIdentityId must not be empty.")] public readonly partial record struct ExternalIdentityId { public Guid Value { get; init; } } ``` Because the rule closes over `TSelf` (`NotEmptyRule`), it sees the value object and reads `Value` through the `IScalarValueObject` constraint. A generic rule must have exactly one type parameter, so the underlying value type is pinned by the constraint — define one rule per primitive (`NotEmptyRule where TSelf : IScalarValueObject`, a `long` variant, and so on). This rule is **not** adapted: a rule whose constraint asks for the scalar keeps closing over the value object. ### Rule against the underlying value (adapted) A rule written against the value (`IValidationRule`) is adapted automatically when applied to a `[Scalar]` type, so one rule serves every scalar backed by that primitive: ```csharp // MyRules/NonSentinelRule.cs — a rule written against the value, not the value object using System; using System.Globalization; using ZodSharp.Core; namespace MyRules; public readonly record struct NonSentinelRule(string? Message = null) : IValidationRule, IZodRule where T : IEquatable { public const string ErrorCode = "invalid_value"; public const string MessageFormat = "Value is a sentinel value, but got {0}"; public bool IsValid(in T value) => !value.Equals(default(T)!); public string GetErrorMessage(in T value) => Message ?? string.Format(CultureInfo.CurrentCulture, MessageFormat, value); string? IZodRule.Code => ErrorCode; string? IZodRule.Origin => "value_object"; } ``` ```csharp [Scalar] [ZodSchema] [NonSentinel(Message = "AssetId must not be empty.")] public readonly partial record struct AssetId { public Guid Value { get; init; } } ``` The generator emits the rule against `Guid` and wraps it in the adapter the value-objects generator emits: ```csharp var assetIdCustomRuleInner0 = new global::MyRules.NonSentinelRule("AssetId must not be empty."); var assetIdCustomRule0 = new global::Purview.ValueObjects.ScalarRuleAdapter>(assetIdCustomRuleInner0); if (!assetIdCustomRule0.IsValid(value)) { (errors ??= new List()).Add( ValidationError.Create( ((global::ZodSharp.Core.IZodRule)assetIdCustomRuleInner0).Code ?? "validation_failed", assetIdCustomRule0.GetErrorMessage(value), EmptyPath, origin: ((global::ZodSharp.Core.IZodRule)assetIdCustomRuleInner0).Origin)); } ``` The reported error keeps the **empty path**, and the error identity (`Code`/`Origin`) is read from the **wrapped** rule, not the adapter. Whether the wrapped rule has an identity is decided when the schema is generated: a rule that implements `IZodRule` is passed with a direct cast, and one that does not is passed as `null`. Generated code never tests a boxed rule at runtime (`(object)rule as IZodRule`), which crashed the Mono AOT compiler, so a scalar with a type-level rule publishes for Blazor WebAssembly with `RunAOTCompilation=true`. :::note If the type has no value-object contract (a plain class with a `Guid` property), the property-level form still works: map the attribute to an `IValidationRule` and put it on `Value`. See [Exposing a rule as a DataAnnotations attribute](../custom-rules/#exposing-a-rule-as-a-dataannotations-attribute) and [Generic rules](../custom-rules/#generic-rules). ::: :::note A `Guid`-backed scalar is validated as a canonical Guid, and exports to JSON Schema as a `pattern`, not `format: uuid` — a `Guid` accepts any 128-bit value, and a time-bound identifier generated by `FastGuid.NewSqlServerGuid()`/`NewPostgreSqlGuid()` is not an RFC UUID. When an identifier *must* be a specific RFC version (for example a `Guid.CreateVersion7()` key), declare it with the UUID rule: ```csharp [Scalar] [ZodSchema] [UUID(UuidVersion.V7)] public readonly partial record struct AssetId { } ``` `UUIDRule` validates a `Guid`'s bytes directly, so the rule adapts onto the scalar; the export is `format: "uuid"` plus a version-pinning `pattern`. Generation strategy alone never implies a version constraint — it is only applied when the runtime validator applies it. ::: ## Testing Assert against the rule's constants instead of duplicating literals, and remember that an adapted scalar rule reads its identity from the wrapped rule: ```csharp // Direct scalar rule — the runtime identity comes from IZodRule or the attribute mapping. var directError = AssetIdSchema.Validate(default).Errors[0]; await Assert.That(directError.Code).IsEqualTo("invalid_asset_id"); await Assert.That(directError.Message).IsEqualTo("AssetId must not be empty."); await Assert.That(directError.Origin).IsEqualTo("value_object"); await Assert.That(directError.Path.Length).IsEqualTo(0); // Adapted underlying-value rule — the canonical identity is the wrapped rule's constant. var adaptedError = TenantIdSchema.Validate(default).Errors[0]; await Assert.That(adaptedError.Code).IsEqualTo(NonSentinelRule.ErrorCode); // "invalid_value" await Assert.That(adaptedError.Message).IsEqualTo("TenantId must not be empty."); // attribute override // With no Message override, the rule's MessageFormat is the message. When the rule runs at the type // level of a scalar, the subject ("Value") is replaced with the scalar's name and the sentinel is // described rather than printed raw: await Assert.That(TenantIdSchema.Validate(default).Errors[0].Message) .IsEqualTo("TenantId has a sentinel value, but got an empty GUID"); // Anywhere else (a member, or the fluent API), the generic subject is kept: await Assert.That( string.Format(CultureInfo.CurrentCulture, NonSentinelRule.MessageFormat, "an empty GUID")) .IsEqualTo("Value has a sentinel value, but got an empty GUID"); ``` ## Diagnostics and troubleshooting | ID | Severity | Meaning | |---|---|---| | ZODSGEN030 | Error | The mapped rule does not implement `IValidationRule` for the target type, or an unbound generic rule could not be closed with it. | | 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. | | ZODSGEN039 | Warning | A rule accepts a `code`/`origin` constructor parameter but does not implement `IZodRule`, so the value never reaches the reported error identity. | | ZODSGEN040 | Warning | An attribute argument has no effect: the resolved rule has no matching constructor parameter and the value is not part of the reported error identity. | | ZODSGEN042 | Warning | A rule does not expose public `const string ErrorCode` / `MessageFormat` constants. | | ZODSGEN044 | Error | A `[ZodSchema]` type uses an automatic `[Scalar]` form but cannot produce a usable schema: the generated `Validate` method is suppressed, or the declared underlying type cannot be represented. | Common causes: - **The rule never runs.** The scalar is missing `[ZodSchema]` (ZODSGEN033), or `[ZodSchema(GenerateValidateMethod = false)]` omitted the `Validate` method that hosts type-level rules. - **The validator does not compile.** `DisableValueObjectsSourceGenerator` disabled the adapter; re-enable it, or express the rule against the value object so no adapter is needed. - **The wrong rule shape resolved.** A rule constrained to `IScalarValueObject` is never adapted; a rule written against the underlying value is always adapted. If a family member cannot validate the target type, ZODSGEN030 is reported and nothing is emitted. ## Related - [Custom Rules](../custom-rules/) — the rule contract, the public `AddRule`/`Rule` API, rule families, and attribute generation. - [Source Generator](../../generator/source-generator/) — the `[ZodSchema]` generator, generated types, and the refinement hook. - [Source Generator Diagnostics](../../generator/source-generator-diagnostics/) — the full `ZODSGEN*` catalog. --- # Compiled Validators and Caching ## CompiledValidator `CompiledValidator` (namespace `ZodSharp.Expressions`) returns a cached delegate for a schema. ```csharp using ZodSharp; using ZodSharp.Expressions; var compiled = CompiledValidator.Compile(schema); var result = compiled(value); // ValidationResult var parser = CompiledValidator.CompileParser(schema); var value = parser(input); // T, throws ZodException on failure ``` | Member | Signature | Returns | |---|---|---| | `Compile` | `Func> Compile(IZodSchema schema)` | validation delegate | | `CompileParser` | `Func CompileParser(IZodSchema schema)` | returns the value or throws `ZodException` | The delegate binds `IZodSchema.Validate` directly, and the result is cached per schema instance with weak keys — so repeated calls for the same schema return the same delegate, and a discarded schema is still collectable. > **It does not make validation faster.** Despite the name, this type adds no optimisation over calling > `schema.Validate(value)` yourself: the delegate performs the same single interface call. It exists to give > you a `Func>` where one is wanted — a cached field, a dictionary of validators, > something that takes a delegate. If you want genuinely faster validation, use the source generator. > > Earlier versions built an expression tree and called `LambdaExpression.Compile` on every invocation. That > removed no dispatch (the tree was a single `Validate` call) and emitted a `DynamicMethod` that is never > reclaimed in a non-collectible load context, so calling it per request leaked managed and native memory. > It also made the package incompatible with Native AOT. Both are fixed; nothing is compiled at runtime now. ## SchemaCache `SchemaCache` (namespace `ZodSharp.Core`) is a `ConcurrentDictionary`-backed cache for expensive schema construction. ```csharp using ZodSharp.Core; var schema = SchemaCache.GetOrCreate("user", () => Z.Object().Field("name", Z.String()).Build()); ``` | Member | Behaviour | |---|---| | `GetOrCreate(string key, Func factory)` | returns the cached instance or creates and stores it (`T : class`) | | `TryGet(string key, out T value)` | typed lookup | | `Remove(string key)` | removes an entry | | `Count` | number of cached entries | | `Clear()` | empties the cache | ### What to know before using it A **fully built** schema is safe to cache and share across threads: validation only reads the rule set and the description. But building a schema is **not** immutable — the fluent rule methods mutate the receiver in place (see [Guarantees and Limitations](../../quality/guarantees-and-limitations/#fluent-rule-methods-mutate-the-receiver)). So finish configuring a schema *before* putting it in the cache, and never apply a fluent rule method to a schema you retrieved from it: that mutates the instance every other caller is sharing. Three further properties of `SchemaCache` worth knowing, because it is process-global: - **The key space is shared.** It is a single static dictionary keyed by an arbitrary string, so two libraries in the same process that both use `"user"` collide. Prefix your keys. - **`GetOrCreate` does not verify the cached type.** The same key used with a different `T` throws `InvalidCastException` at the point of retrieval. - **There is no eviction or size bound.** A key derived from user input grows the dictionary for the lifetime of the process. A static field, or your container's singleton lifetime, is usually a better fit than this cache. Prefer it when you genuinely need lookup by name. ## The source generator alternative For the highest performance, prefer the compile-time source generator: `[ZodSchema]` emits a static validator with no runtime compilation or dispatch overhead. See [Source Generator](../../generator/source-generator/). --- # Source Generator Mark a class, struct, or record with `[ZodSchema]` and the generator emits a static, zero-allocation validator at compile time. The `[ZodSchema]` attribute is `ZodSharp.ZodSchemaAttribute`, defined in the `Purview.ZodSharp` assembly (as is `[ErrorType]`), so no extra package is needed beyond `Purview.ZodSharp`. The generator emits no marker types into your project, so every project in a solution binds to the same attribute. ```csharp using System.ComponentModel.DataAnnotations; using ZodSharp; [ZodSchema] public class User { [Required] [StringLength(50, MinimumLength = 3)] public string Name { get; set; } = string.Empty; [Range(0, 120)] public int Age { get; set; } [EmailAddress] public string? Email { get; set; } } var result = UserSchema.Validate(user); var validated = UserSchema.Parse(user); // throws ZodException on failure ``` ## Generated types For a `[ZodSchema]` target type `{TypeName}`, the generator emits: | Artifact | Shape | |---|---| | `{TypeName}Schema` | static partial class — the validator; access mirrors the target (public/internal/private for private nested types); the name is overridable with `SchemaName`; contains `Validate`, `ValidateAsync`, `Parse`, `BuildJsonSchema`, the member-level API (see [Generated members](https://github.com/purview-dev/zodsharp/blob/main/docs/wiki/generator#generated-members)), and (when composition is enabled) `ApplyAnd`, `ApplyOr`, `ApplyRefine` | | `{TypeName}SchemaValidator` | `sealed partial class {TypeName}SchemaValidator : IZodSchemaValidator<{TypeName}>, IZodMemberValidator<{TypeName}>, IZodMemberResolver<{TypeName}>, IZodUntypedValidator, IZodJsonPathMapper, IJsonSchemaProvider` — DI-friendly adapter that forwards to `{TypeName}Schema`; emitted only for the primary schema, and named `{SchemaName}Validator` when `SchemaName` is set | | `{TypeName}Validator` | `sealed partial class {TypeName}Validator : IValidateOptions<{TypeName}>` — emitted only when `IValidateOptions` support is enabled (and the target is a class). On .NET 11 it also implements `IAsyncValidateOptions<{TypeName}>` when the type has asynchronous rules | | `[assembly: ZodSchemaGenerated(typeof({TypeName}), typeof({TypeName}SchemaValidator))]` | registration marker consumed by `IZodSchemaFactory` assembly scanning; emitted only for primary, non-nested schemas | | `ZodSharp.Generated.{Assembly}ZodSharpSchemas` | one per assembly: `Register(IZodSchemaFactory)` registers every generated validator by name — trim- and AOT-safe (see [Trim-safe registration](https://github.com/purview-dev/zodsharp/blob/main/docs/wiki/generator#trim-safe-registration)) | | `{Assembly}ZodSharpServiceCollectionExtensions` | one per assembly, in `Microsoft.Extensions.DependencyInjection`, when the project references Microsoft.Extensions.DependencyInjection: `services.AddZodSharp{Assembly}Schemas()` | `BuildJsonSchema` returns a `ZodSharp.JsonSchema.JsonSchemaDefinition` built from the same metadata as `Validate`, so a generated `[ZodSchema]` schema can be exported through `Z.ToJsonSchema` without rebuilding its rules by hand. See [JSON Schema Export](../../serialization/jsonschema-export/#export-a-generated-zodschema). ```csharp // Value-first composition methods (EnableComposition, default true): var adult = UserSchema.ApplyRefine(user, u => u.Age >= 18, "Must be adult"); var both = UserSchema.ApplyAnd(user, u => u.Name.Length > 5, "Name too short"); var either = UserSchema.ApplyOr(user, u => u.Age < 18, "Must be an adult or a minor with consent"); ``` A failed composition reports code `refinement_failed` with category `custom`, the same identity as a runtime `Refine`. ## Generated members Besides `Validate`, `Parse` and `BuildJsonSchema`, every `{TypeName}Schema` exposes: | Member | Purpose | |---|---| | `Paths.{Member}` | A `ZodPath` per member (`OrderSchema.Paths.CustomerName`). Use it to place an issue on a member (`RefineCtx.AddIssue`, a custom async method) or to match an error's `FullPath`, instead of hand-writing member names. | | `JsonNames.Get(memberName)` | The explicit `[JsonPropertyName]` of a member, or `null` when it has none. | | `ToJsonNamedPath(path, namingPolicy)` | Renames every member segment of an error path to its JSON name: the `[JsonPropertyName]` when present, otherwise `namingPolicy` (for example `JsonNamingPolicy.CamelCase.ConvertName`), otherwise the C# name. Recurses into nested schemas, so `Lines[0].Sku` becomes `lines[0].sku`. Reflection-free. | | `RequiresAsyncValidation` | A `const bool`: `true` when some rule in the graph only runs asynchronously (a custom async method, an `AsyncValidationAttribute`, an `IAsyncValidatableObject` target, or a nested schema that has one). | | `ValidateAsync(value, services, cancellationToken)` | Validates the whole graph asynchronously. Nested schemas are awaited and receive the same `services`; completes synchronously when `RequiresAsyncValidation` is `false`. | | `ValidateMember(value, memberName)` / `ValidateMemberAsync(value, memberName, services, cancellationToken)` | Validates one member — see [Validating a single member](https://github.com/purview-dev/zodsharp/blob/main/docs/wiki/generator#validating-a-single-member). | | `IsValidatableMember(memberName)` | `true` when the member has rules `ValidateMember` runs. | | `TryResolveMember(value, path, out container, out memberName)` | Walks `value` along an error path to the object that owns the failing member — see [Resolving the owning object](https://github.com/purview-dev/zodsharp/blob/main/docs/wiki/generator#resolving-the-owning-object). | `ValidateCore`, `ValidateCoreAsync`, `AppendJsonNamedPath` and the `TryResolveMember` overload with an offset are public only so nested schemas can call each other; they are hidden from IntelliSense and are not part of the supported API. ```csharp using System.Text.Json; using ZodSharp.Core; var result = OrderSchema.Validate(order); foreach (var error in result.Errors) { // "Lines[0].Sku" - the canonical, C#-named path. var key = error.FullPath.ToDottedString(); // "lines[0].sku" - the JSON-named path, for a client that sees the JSON shape. var jsonKey = OrderSchema.ToJsonNamedPath(error.FullPath, JsonNamingPolicy.CamelCase.ConvertName).ToDottedString(); if (error.FullPath == OrderSchema.Paths.CustomerName) { // The customer name failed. } } ``` Generated validators locate collection and dictionary errors with typed index and key segments, so `ToDottedString()` renders `Lines[0].Sku` and `ByCode[abc].Sku` (an all-digit or punctuated key is quoted: `ByCode["0"]`, `ByCode["a.b"]`). Keys are rendered with the invariant culture. See [Core Concepts](../../concepts/core-concepts/) for the path format. ### Validating a single member `ValidateMember(value, memberName)` runs one member's **own** rules: its attributes, its `[Compare]`, and a collection's null-element checks. It does not recurse into a nested schema, and it does not run type-level rules, `IValidatableObject`, the `OnZodValidate` refinement or the custom async method. Every error it returns therefore belongs to that member, which is what a host doing field-level validation (a Blazor field change) needs. Member names are matched ordinally on the C# name; an unknown member succeeds. ```csharp var nameOnly = OrderSchema.ValidateMember(order, nameof(Order.CustomerName)); var linesOnly = OrderSchema.ValidateMember(order, nameof(Order.Lines)); // null elements only, not each line's rules if (OrderSchema.IsValidatableMember(nameof(Order.CustomerName))) { // The member has rules. } ``` `ValidateMemberAsync` also runs the member's asynchronous rules: `AsyncValidationAttribute`s on the member, and the custom async method when it is marked `[ZodAsyncMember]` for that member (see [Custom async validation](https://github.com/purview-dev/zodsharp/blob/main/docs/wiki/generator#custom-async-validation)). ### Resolving the owning object `TryResolveMember` walks a value along an error path and returns the object that owns the last member segment, without reflection: ```csharp var error = OrderSchema.Validate(order).Errors[0]; // Lines[0].Sku if (OrderSchema.TryResolveMember(order, error.FullPath, out var container, out var memberName)) { // container is order.Lines[0], memberName is "Sku". } ``` A path that ends in an index or key (a null element at `Lines[0]`) resolves to the owner of the collection member (`order`, `"Lines"`). A path that cannot be followed resolves to the nearest object reached and returns `false`; a model-level error resolves to the root with an empty member name. The Microsoft.Extensions.Validation bridge uses this to give every error the `Container` and `Name` Blazor needs to place the message on the right field. The container is never a struct. A struct cannot own a field (Blazor's `FieldIdentifier` rejects a value-type model, and a boxed copy is not the instance a form binds), so an error inside a struct member resolves to the nearest reference-type owner and its member: `Email.Value`, where `Email` is a value object on `SignUp`, resolves to (`signUp`, `"Email"`), and `Stops[1].Latitude` over a `List` to (`signUp`, `"Stops"`). ### Depth guard Generated validators count the nesting depth as they walk a graph and throw `InvalidOperationException` when it reaches `ZodValidationDepth.MaxDepth` (default `32`, matching Microsoft.Extensions.Validation's `ValidationOptions.MaxDepth`). A graph that deep is almost always a cycle, so it fails clearly instead of overflowing the stack. The root is validated at depth `0`. `ZodValidationDepth.MaxDepth` is process-wide; `AddZodSharpValidation()` (`Purview.ZodSharp.Extensions.Validation`) sets it from `ValidationOptions.MaxDepth`, so a cycle fails at the same depth however it is validated. ### Messages Generated errors are formatted at runtime from a named template, kept in `ValidationError.MessageFormat`. `Parameters` carries `displayName`, `member`, `declaringType` and the rule's values (`minimum`, `maximum`, `count`, …), so a host can re-format or localize the message without reflection. A custom `ErrorMessage` keeps its DataAnnotations positional meaning (`{0}` is the display name) and is formatted with the never-throw `ZodMessageFormat` engine, so a stray brace is shown literally rather than throwing. A placeholder the attribute does not supply is reported at build time as `ZODSGEN052`. See [Source Generator DataAnnotations](../source-generator-dataannotations/#error-message-customization). ## Attribute options All options are optional. | Property | Default | Purpose | |---|---|---| | `SchemaName` | `null` | Overrides the generated schema class name (default `{TypeName}Schema`). The DI adapter becomes `{SchemaName}Validator`. | | `GenerateValidateMethod` | `true` | Set to `false` to omit `Validate` (and the members that depend on it). | | `GenerateParseMethod` | `true` | Set to `false` to omit `Parse`. `Parse` requires `Validate`, so it is also omitted when `GenerateValidateMethod = false`. | | `EnableComposition` | `true` | Emits `ApplyAnd`, `ApplyOr`, `ApplyRefine` value-first composition methods. | | `CustomValidationMethodName` | `null` | Name of an async custom validation method; default lookup name `CustomValidationAsync`. Mutually exclusive with the synchronous `OnZodValidate` refinement hook. | | `GenerateIValidateOptions` | `false` | Force `IValidateOptions` generation. | | `SuppressIValidateOptions` | `false` | Opt out even when auto-detection would enable it. | | `ValidateEnumValues` | `true` | Set to `false` to skip the automatic enum validation for the type's enum properties. | | `GenerateJsonSchema` | `true` | Set to `false` to omit the JSON Schema export members (`BuildJsonSchema` and the `IJsonSchemaProvider` on the adapter), keeping the type out of the cross-platform contract surface. A type referenced by an exported type is reported as `ZODSGEN045`. | | `AllowNulls` | unset | Whether this type's collection properties may contain `null` elements (or `null` dictionary values). Unset applies the assembly default; `true`/`false` overrides it for this type. See [Collection null items](https://github.com/purview-dev/zodsharp/blob/main/docs/wiki/generator#collection-null-items). | :::note `Parse`, the value-first composition methods (`ApplyAnd`/`ApplyOr`/`ApplyRefine`), the `IZodSchemaValidator` adapter and the `IValidateOptions` validator all depend on `Validate`. Setting `GenerateValidateMethod = false` omits them together. ::: ## Custom async validation Declare a partial `{TypeName}SchemaValidator` (or a static method on the model type): ```csharp public partial class UserSchemaValidator { public async ValueTask> CustomValidationAsync(User value, CancellationToken ct) { await Task.Delay(1, ct); return ValidationResult.Success(value); } } ``` Requirements: - Signature `ValueTask> Name(T value, CancellationToken ct)`, or — to use the caller's services — `ValueTask> Name(T value, IServiceProvider services, CancellationToken ct)`. - Default lookup name `CustomValidationAsync` unless overridden with `CustomValidationMethodName` on the `[ZodSchema]` attribute. - A method declared on the model type must be `static`; a method on the generated `{TypeName}SchemaValidator` partial may be an instance method. - The generated `ValidateAsync` runs every member's rules (awaiting nested schemas' `ValidateAsync`), then the type rules, then awaits the custom method, and merges the error sets. Error paths returned by the method are relative to the type; nested types' custom methods are reported under the member's path. - A synchronous `Validate` never runs the custom method. ### Service-aware rules and `[ZodAsyncMember]` A service-aware method receives the `services` passed to `ValidateAsync` (the request's services under ASP.NET Core, the circuit's under Blazor Server). Calling `ValidateAsync` without services on such a type throws `InvalidOperationException` naming the type. The same `services` are threaded through nested schemas. `[ZodAsyncMember(nameof(X))]` declares which members the method validates. `ValidateMemberAsync` then runs the method only for those members and keeps only the errors it reports under that member's path; without the attribute the method runs only on full validation. ```csharp using System.ComponentModel.DataAnnotations; using ZodSharp; using ZodSharp.Core; [ZodSchema] public partial class Account { [Required] public string? Handle { get; set; } [ZodAsyncMember(nameof(Handle))] internal static async ValueTask> CustomValidationAsync( Account value, IServiceProvider services, CancellationToken ct ) { var handles = (IHandleStore)services.GetService(typeof(IHandleStore))!; return await handles.IsTakenAsync(value.Handle!, ct) ? ValidationResult.Failure( ValidationError.Create("handle_taken", "Handle is taken", AccountSchema.Paths.Handle) ) : ValidationResult.Success(value); } } public interface IHandleStore { ValueTask IsTakenAsync(string handle, CancellationToken ct); } ``` ```csharp var full = await AccountSchema.ValidateAsync(account, services, cancellationToken); var handleOnly = await AccountSchema.ValidateMemberAsync(account, nameof(Account.Handle), services, cancellationToken); ``` `RequiresAsyncValidation` is `true` for `Account`, and for every type that nests it. A type whose graph has no asynchronous rule completes `ValidateAsync` synchronously, so awaiting it costs nothing. Because `ValidateAsync` exists, CA1849 (when enabled) would report every `Validate` and `ValidateMember` call inside async code. It does not: the analyzer package ships the `ZODSUP001` suppressor, which removes those reports by call target. The synchronous call is the cheap path (no `ValueTask`, no state machine) and never blocks. `ValidateAsync` is the call to make when the graph has asynchronous rules — `RequiresAsyncValidation` is the compile-time answer and `ZODSGEN048` says so at build time; a synchronous `Validate` of such a type throws rather than skip them. Nothing else is suppressed: CA1849 still reports for `Task.Wait`, `Thread.Sleep` and another library's `Validate`/`ValidateAsync` pair. :::caution The async custom validation method is mutually exclusive with the `OnZodValidate` refinement hook. A model must declare exactly one of the two — declaring both is an error (ZODSGEN029). ::: ## Refinement hook (`OnZodValidate`) Refinement rules are written as a **generator-declared partial method** on the model. The generator emits the declaration, so the IDE offers the implementation with the correct signature and no name is resolved by convention: ```csharp using ZodSharp; using ZodSharp.Schemas; // RefineCtx [ZodSchema] public partial class Order { public decimal Total { get; set; } partial void OnZodValidate(RefineCtx context) { if (context.Value.Total < 0) context.AddIssue("invalid_range", "Total cannot be negative", OrderSchema.Paths.Total); } } ``` Requirements: - The target type **and every containing type** must be declared `partial` (ZODSGEN034). This is the only type-shape requirement the hook adds. - The signature must be `partial void OnZodValidate(RefineCtx context)`, where `T` is the model type (ZODSGEN035). The parameter is a plain by-value `RefineCtx`. - The hook runs for **every** entry point into the generated schema — `Validate`, `Parse`, the `IZodSchemaValidator` adapter, `IValidateOptions`, and any factory that validates through the schema — so a rule written here behaves exactly like an attribute rule. - A type that does not implement the hook allocates nothing: the generated `Validate` only builds a `RefineCtx` and calls the hook when a body exists. - Issues are reported through `context.AddIssue(code, message, path)` — `path` is a `ZodPath` (use the generated `{TypeName}Schema.Paths` constants) or a `string[]` — or `context.AddIssue(ValidationError)`, and are merged with the attribute-rule issues into one result. An issue added with only a message has code `refinement_failed` and category `custom`. - Refinements state is reported by **ZODSGEN036** if a member still uses the retired `IEnumerable Validate()` contract, which the generator no longer binds. :::note Alongside the hook, the generator emits one `internal static` bridge member, `InvokeZodRefinementHook(T value, RefineCtx context)`, on the target type. A classic `partial` method is private and the generated `{Type}Schema` is a different type, so the bridge is what lets `Validate` reach the hook while keeping the hook itself optional. It is not part of the type's API and must not be implemented by hand. ::: ## IValidateOptions support Generated options validators are enabled by: 1. `GenerateIValidateOptions = true` on the attribute, or 2. auto-detection: `GenerateIValidateOptions` unset, target is not a value type, and the type name ends with a configured suffix (default `Options` or `Settings`), or 3. MSBuild override. MSBuild switches: | Property | Default | Behaviour | |---|---|---| | `DisableZodSharpSourceGenerator` | unset | disables the generator entirely when truthy | | `ZodSharpAutoGenerateOptionsValidators` | `true` | auto-detect `IValidateOptions` (only explicit `false` disables) | | `ZodSharpAutoGenerateOptionsValidatorSuffixes` | `Options;Settings` | semicolon/comma-separated suffix list | | `ZodSharpGenerateJsonSchemas` | `true` | assembly-wide JSON Schema export switch; explicit `false` omits `BuildJsonSchema` and `IJsonSchemaProvider` for every type | | `ZodSharpEnumSerialization` | `Number` | `String` makes enums export as their member names, matching a global `JsonStringEnumConverter` | | `ZodSharpEnumNamingPolicy` | `Unchanged` | `CamelCase` applies `JsonNamingPolicy.CamelCase` to string-serialized enum names | | `ZodSharpAllowNulls` | `false` | assembly-wide default for whether collection properties may contain `null` elements; explicit `true` accepts them | | `ZodSharpDefaultImmutableArrayBehavior` | `ValidationError` | handles uninitialized `ImmutableArray` as missing, or `TreatAsEmpty` to run collection rules against an empty array | | `ZodSharpDateTimeOffsetSerialization` | `PreserveOffset` | `Utc` narrows `[NonSentinel] DateTimeOffset` to a zero-offset wire contract; the matching converter must also reject non-UTC input. Both modes export an exact sentinel pattern | Each failure of a generated options validator reads `{path}: {message} ({code})` (`Bucket: Bucket is required (missing_field)`), or `{message} ({code})` for a root error. On .NET 11, when the type has asynchronous rules (a custom async method, an `AsyncValidationAttribute` or `IAsyncValidatableObject`), the generated `{TypeName}Validator` also implements `IAsyncValidateOptions`. The platform calls the asynchronous path only from `ValidateOnStart()`; synchronous options access (`IOptions.Value`, reloads) runs the synchronous rules only. A type that also has a hand-authored `[OptionsValidator]` class (Microsoft.Extensions.Options' own generator) would get two validators for the same options (SYSLIB1205), so the ZodSharp validator is not generated and `ZODSGEN050` is reported. Keep one: remove `[OptionsValidator]`, or set `[ZodSchema(SuppressIValidateOptions = true)]`. For options validated through the factory instead, see [Dependency Injection](../../hosting/dependency-injection/#validating-options-objects). ## Trim-safe registration Every assembly that declares `[ZodSchema]` types gets one registration class that names each generated validator directly — no attribute scanning and no `Activator` — so it is safe under trimming and Native AOT (including Blazor WebAssembly): ```csharp // ZodSharp.Generated.{Assembly}ZodSharpSchemas, generated var factory = new ZodSchemaFactory(); ZodSharp.Generated.ContosoOrdersZodSharpSchemas.Register(factory); ``` When the project references Microsoft.Extensions.DependencyInjection, a matching extension is generated too: ```csharp builder.Services.AddZodSharpContosoOrdersSchemas(); ``` `{Assembly}` is the assembly name in PascalCase with separators removed: `Contoso.Orders` becomes `ContosoOrders`, `my-app` becomes `MyApp`, and a name that starts with a digit gains a leading `_`. The extension adds a `ZodSchemaRegistration` and ensures `AddZodSharpFactory()` has registered the factory, which applies every registration when it is first resolved — so calls from several model assemblies compose. Prefer it to `RegisterFromAssembly`, which discovers validators through metadata and is not trim-safe. See [Dependency Injection](../../hosting/dependency-injection/#registering-source-generated-validators). ## Unions and closed hierarchies (C# 15) With C# 15 (`net11.0`), a member typed as a `union` is validated with the schema of the **active case**, and a member typed as a `closed` class with the schema of the derived type it holds. The union's `Value` is not part of the path: ```csharp [ZodSchema] public class Cat { [MinLength(2)] public string? Name { get; set; } } [ZodSchema] public class Dog { [Range(1, 100)] public int Weight { get; set; } = 1; } public union Pet(Cat, Dog); [ZodSchema] public class Owner { public Pet Pet { get; set; } } // A Cat named "A" reports too_small at "Pet.Name"; TryResolveMember resolves it to the Cat instance. ``` Every derived type of a closed class needs its own `[ZodSchema]`; one without is not validated and is reported as `ZODSGEN049`. ## Razor components Razor compiles `.razor` files with its own source generator, and source generators cannot see each other's output. A `[ZodSchema]` type declared in a `@code` block therefore gets no schema; the generator reports `ZODSGEN051`. Declare form models in `.cs` files. ## Collection null items A collection property rejects `null` elements by default: the generated validator reports `invalid_type` at the element's own path (`["Items", 0]`), matching Zod's per-element issue path. The behaviour is controlled by the same setting at several levels, and the most specific statement wins: | Level | Declaration | |---|---| | Property (or positional record parameter) | `[CollectionBehavior(AllowNulls = true)]` / `[CollectionBehavior(AllowNulls = false)]` | | Element type | an explicitly nullable element (`T?`, for example `List` or `Child?[]`) allows nulls | | Containing type | `[ZodSchema(AllowNulls = true)]` / `[ZodSchema(AllowNulls = false)]` | | Assembly | `[assembly: ZodSharpSchemaDefaults(AllowNulls = ...)]`, then the `ZodSharpAllowNulls` MSBuild property | Precedence, highest first: the property attribute, the element's `T?` annotation, the containing type's `[ZodSchema]` option, the assembly attribute, the `ZodSharpAllowNulls` MSBuild property, then the built-in default (reject). ### Dictionaries A property whose type implements `IDictionary` or `IReadOnlyDictionary` is a **map**, not a sequence: `Dictionary`, `SortedDictionary`, `ConcurrentDictionary`, `ImmutableDictionary` and custom implementations all qualify. The generated validator iterates the entries, validates each **value** against the value type's nested schema (discovered recursively, with or without its own `[ZodSchema]`), and reports a failure at the entry's key — `["Variants", "blue"]` — matching Zod's `z.record` issue path. Keys are used only in error paths; they are not validated. The null-items setting above applies to a map's **values**: `Dictionary` rejects a null value by default, while `Dictionary` or `[CollectionBehavior(AllowNulls = true)]` accepts one. `Dictionary` has no null case at all, so `[CollectionBehavior]` on it is reported as `ZODSGEN046`. `[Length]`/`[MinLength]`/`[MaxLength]` count entries, exactly as they count elements for a sequence. ```csharp using ZodSharp; [assembly: ZodSharpSchemaDefaults(AllowNulls = false)] [ZodSchema] public class RepositoryInventoryResponse { // Rejected by default: a null element reports invalid_type at ["Items", index]. public RepositoryInventoryItem[] Items { get; set; } = []; // Explicit opt-in for one property. [CollectionBehavior(AllowNulls = true)] public RepositoryInventoryItem[] OptionalItems { get; set; } = []; // A map: the setting governs null values, and failures are reported at ["Variants", key]. public Dictionary Variants { get; set; } = []; } ``` :::note `[CollectionBehavior]` and `[ZodSchema(AllowNulls = ...)]` only take effect when the named argument is present; an attribute that leaves it out defers to the next level. `[CollectionBehavior]` with options that cannot affect the property is reported as `ZODSGEN046`. ::: A collection whose elements may be null exports its `items` schema with a `null` branch, mirroring how a nullable property is exported. A dictionary exports `{ "type": "object", "additionalProperties": … }` — see [JSON Schema Export](../../serialization/jsonschema-export/#dictionaries). ### Struct collections and default immutable arrays Struct-backed collections are validated without reference-type null checks. Nullable struct collections are unwrapped only when present, for count rules, nested validation, asynchronous validation and member resolution. Element nullability is independent of collection nullability. An uninitialized `ImmutableArray` (`IsDefault`) is treated as **missing**, not enumerated: `[Required]` reports `missing_field` (honoring its configured message), a nullable member accepts it unless required, and a non-nullable member reports `invalid_type`. `ImmutableArray.Empty` is present and valid unless a count rule rejects it. Use `DefaultImmutableArrayBehavior.TreatAsEmpty` to validate an uninitialized array as empty instead: ```csharp [assembly: ZodSharpSchemaDefaults( DefaultImmutableArrayBehavior = DefaultImmutableArrayBehavior.TreatAsEmpty)] [ZodSchema] public class Inventory { [CollectionBehavior(DefaultImmutableArrayBehavior = DefaultImmutableArrayBehavior.ValidationError)] public ImmutableArray Items { get; set; } } ``` Precedence: the property's `[CollectionBehavior(DefaultImmutableArrayBehavior = ...)]` (also supported on positional record parameters), the assembly attribute, the `ZodSharpDefaultImmutableArrayBehavior` MSBuild property (`ValidationError` or `TreatAsEmpty`), then `ValidationError`. These options are independent of `AllowNulls`. `TreatAsEmpty` does not accept a missing nullable wrapper as empty and does not mutate the model. It normalizes the value used by generated collection rules; JSON Schema remains an array schema. ## DateTimeOffset wire form The JSON Schema generated for `[NonSentinel] DateTimeOffset` depends on the wire form assumed for the value. By default offsets are preserved and the pattern is offset-aware: it rejects every offset-equivalent representation of the instant-based bounds, so no unsupported marker is emitted. An optional narrower UTC contract is available: ```csharp // Directory.Build.props Utc ``` ```csharp using ZodSharp; [assembly: ZodSharpSchemaDefaults( DateTimeOffsetSerialization = DateTimeOffsetSerialization.Utc)] [ZodSchema] [DateTimeOffsetBehavior(DateTimeOffsetSerialization.Utc)] public sealed class Observation { public DateTimeOffset ObservedAt { get; set; } // Override the project/type default for one property. [DateTimeOffsetBehavior(DateTimeOffsetSerialization.PreserveOffset)] [JsonConverter(typeof(PreserveOffsetDateTimeOffsetConverter))] public DateTimeOffset LegacySeenAt { get; set; } } ``` Precedence, highest first: `[DateTimeOffsetBehavior]` on the property (or positional record parameter), the containing type's attribute, the assembly attribute, the `ZodSharpDateTimeOffsetSerialization` MSBuild property, then `PreserveOffset`. The attribute shapes the schema only; pair `Utc` with the integration package's `UtcDateTimeOffsetConverter` (and `PreserveOffset` under a global UTC converter with `PreserveOffsetDateTimeOffsetConverter`). Both modes export an exact sentinel pattern; `Utc` additionally narrows the accepted wire form to a zero offset. See [JSON Schema Export](../../serialization/jsonschema-export/#temporal-sentinels-and-the-datetimeoffset-wire-form). ## Automatic enum validation Every non-flags enum property is validated automatically: the generated validator rejects a value that is not a defined member of the enum type. The check is emitted as an `EnumRule` (see [Validation Rules Reference](../../schemas/validation-rules-reference/#enum-rules)) and reports `invalid_enum_value`. A member that is defined but never a valid value can be excluded globally by marking it `[ZodIgnore]`, and excluded for a single property with `[DeniedValues]`: ```csharp using System.ComponentModel.DataAnnotations; using ZodSharp; public enum ExampleEnum { [ZodIgnore] Unspecified, AValidValue, AnotherValidValue, } [ZodSchema] public class Model { // Rejects anything that is not AValidValue or AnotherValidValue. public ExampleEnum Status { get; set; } // Also rejects AnotherValidValue for this property only. [DeniedValues(ExampleEnum.AnotherValidValue)] public ExampleEnum SecondaryStatus { get; set; } } ``` The automatic validation is skipped when: - the enum is declared `[Flags]` — a combination is a valid value without being a defined member; - the property declares an explicit `[AllowedValues]` allow-list, which governs the property instead; - the schema opts out with `[ZodSchema(ValidateEnumValues = false)]`. A nullable enum property is validated only when it is not `null`. ## What is validated - Properties must be public, non-static, non-indexer. - Validation is emitted for a property when it carries any DataAnnotations attribute, its type is a source-defined complex type with a nested schema, or its type is an enum (see [Automatic enum validation](https://github.com/purview-dev/zodsharp/blob/main/docs/wiki/generator#automatic-enum-validation)). - Classes, structs, and records are supported; structs do not receive `IValidateOptions` (ZODSGEN028 if requested). - Nested complex types are discovered recursively and get their own generated `{TypeName}Schema`, even when the nested type does not itself carry `[ZodSchema]`. - Nullable properties are null-guarded before value-set/type validation; a nullable target rejects `null` with `invalid_type`. - A target implementing `IValidatableObject` is validated by it after the members and type rules pass, as DataAnnotations and Microsoft.Extensions.Validation do. On .NET 11, `IAsyncValidatableObject` and `AsyncValidationAttribute` members run in `ValidateAsync`; a synchronous `Validate` of such a type throws `InvalidOperationException` (`ZODSGEN048` warns at build time). See [Source Generator DataAnnotations](../source-generator-dataannotations/#ivalidatableobject-and-asynchronous-dataannotations). - A `Purview.ValueObjects` scalar marked with `[Scalar]` can carry `[ZodSchema]` on the same type; the generated validator validates the scalar as a unit and reports an empty path. A rule written against the scalar's underlying value is adapted automatically, or resolved against the unwrapped value and null-guarded when the underlying value is a `Nullable` — see [Value Objects Integration](../../schemas/value-objects-integration/). - A rule marked with the parameterless `[ZodRule]` generates a matching validation attribute. Every built-in rule ships its attribute inside `Purview.ZodSharp` (in the `ZodSharp.Rules` namespace — `[Email]`, `[E164]`, `[Regex]`, `[NonSentinel]`, `[MinValue]`, `[Even]`, …; names that collide with `System.ComponentModel.DataAnnotations` use a `Zod` suffix such as `[MinLengthZod]`), so they can annotate a member or a scalar value object directly — see [Built-in attributes](../../schemas/custom-rules/#built-in-attributes-shipped-with-purviewzodsharp). See [Source Generator DataAnnotations](../source-generator-dataannotations/) for the attribute coverage and structured issue shape, [Custom Rules](../../schemas/custom-rules/) for extending validation with your own rules and attributes, and [Source Generator Diagnostics](../source-generator-diagnostics/) for the `ZODSGEN*` diagnostics. --- # Source Generator DataAnnotations The `[ZodSchema]` generator reads `System.ComponentModel.DataAnnotations` attributes and emits direct, typed codegen — no reflection at runtime. ## Supported attributes | Attribute | Generated behaviour | Failure code | |---|---|---| | `[Required]` | rejects `null`; on a string also `""` and whitespace-only text, unless `AllowEmptyStrings = true` (which rejects only `null`) — `RequiredAttribute`'s semantics | `missing_field` | | `[Length(min, max)]` | min/max size; applies to strings, arrays (incl. jagged/rectangular), and countable collections | strings: `invalid_length`; collections: `too_small` / `too_big` | | `[StringLength(max)]` / `[StringLength(max, MinimumLength=min)]` | string size limits via direct `.Length` | `invalid_length` | | `[MinLength(n)]` | only checked when `n > 0` | `too_small` | | `[MaxLength(n)]` | only checked when `n >= 0` | `too_big` | | `[Range(...)]` | inclusive (or exclusive) numeric/parsed bounds | `invalid_range` | | `[RegularExpression(pattern)]` | compiled `Regex` field, checked on non-empty strings | `invalid_string` | | `[AllowedValues(...)]` | typed equality checks against the allowed set | `invalid_value` | | `[DeniedValues(...)]` | typed equality checks against the denied set; for an enum property the values are absorbed into the automatic enum rule instead | `invalid_value` | | `[EmailAddress]` | reuses `ZodSharp.Rules.EmailRule` on non-empty strings | `invalid_string` | | `[Url]` | reuses `UrlRule` | `invalid_string` | | `[Phone]` | reuses `PhoneRule` | `invalid_string` | | `[CreditCard]` | reuses `CreditCardRule` | `invalid_string` | | `[Base64String]` | reuses `Base64StringRule` | `invalid_string` | | `[Compare(otherProperty)]` | typed equality between two properties | `mismatch` | | `[Display(Name=...)]` | not validated; `Name` used as the display name in messages and `{0}` placeholders. With `ResourceType`, the localized name is read from the resource property at runtime | — | `[Length]` follows DataAnnotations null semantics: `null` is valid unless `[Required]` is also present. Every generated error reports a `Category` (the Zod issue code: `invalid_type`, `too_small`, `too_big`, `invalid_format`, `invalid_value`, `custom`), an `Origin` (`string`, `number`, `array`, `enum`, …), and `Parameters` with `displayName`, `member` and `declaringType`. `[Required]` reports category `invalid_type` and the message `"{displayName} is required"`; `[StringLength]`/`[Length]` on a string report `invalid_length` with the direction in `Category` (`too_small`/`too_big`) and both bounds, the same identity as the runtime `LengthRule`/`StringLengthRule`. :::caution **Behaviour change in 3.0.0.** The generated `[Required]` check on a string was inverted: the default (`AllowEmptyStrings = false`) only rejected `null`, and `AllowEmptyStrings = true` rejected `""`. It now matches `RequiredAttribute`. A model that relied on `""` passing a bare `[Required]` must supply a value or set `AllowEmptyStrings = true`. ::: `[RequiredZod]` (the generated `RequiredRule` attribute) mirrors `[Required]`: it is emitted outside the generator's non-null guard, so an absent value is reported as `missing_field` even though every other rule bound to the member is skipped for `null`. Its `AllowEmptyStrings` property has the same name and meaning as `RequiredAttribute.AllowEmptyStrings`, and it additionally exposes `TrimWhitespace`, so it is a drop-in replacement for `[Required]`. ## Positional records A positional record declares its properties from the primary-constructor parameter list, and the C# compiler applies an attribute that is valid on both a parameter and a property to the parameter only. The generator therefore reads the attributes on the synthesized property *and* on the primary-constructor parameter, so a validation attribute written directly on a positional parameter is honoured: ```csharp [ZodSchema] public sealed record RepositoryInventorySummary( [NonSentinel] string Name, [NonSentinel] string CanonicalReference, [NullOrNonWhiteSpace] string? DefaultBranch, [NonSentinel] DateTimeOffset LastObservedAt ); ``` `[property: NonSentinel]` (the explicit property target) is equally honoured; when both targets carry the same attribute type the property target wins and the rule runs once. Declared properties, classes, and record structs are unaffected. ## Enum properties Enum properties are validated automatically — the generator rejects a value that is not a defined member of the enum type with `invalid_enum_value`. The check is emitted as `ZodSharp.Rules.EnumRule`. Members can be excluded with `[ZodIgnore]` (on the enum member, for every property of that type) or `[DeniedValues]` (on the property only). `[Flags]` enums and properties with an explicit `[AllowedValues]` allow-list are not auto-validated, and the whole feature is disabled with `[ZodSchema(ValidateEnumValues = false)]`. See [Source Generator](../source-generator/#automatic-enum-validation) for the full rules and examples. ## Size validators and structured issues Size attributes generate direct `Length` or `Count` access when possible: - `string` → `.Length`. - arrays (including rectangular arrays) → `.Length`. - jagged arrays → outer-array `.Length`. - countable collections → `.Count`. - `IEnumerable` / `IEnumerable` → a single counted pass via `CollectionCountHelper.GetCount` (fast paths for `ICollection`, `IReadOnlyCollection`, and non-generic `ICollection`). Structured size failures expose the same metadata as the runtime API: - `Code`: `too_small` or `too_big`. - `Origin`: `string` for strings, `record` for dictionaries, `array` for arrays and every other countable/`IEnumerable` collection (the Zod type of the JSON, as the runtime schemas report it). - `Minimum` / `Maximum`: the inclusive bound. - `Inclusive`: `true`. - `Path`: the property path. ```csharp [ZodSchema] public sealed class Basket { [Required] [Length(2, 5)] public List? Items { get; set; } } var result = BasketSchema.Validate(new Basket { Items = ["apple"] }); // result.Errors[0].Code == "too_small" // result.Errors[0].Minimum == 2 // result.Errors[0].Origin == "array" // result.Errors[0].Inclusive == true ``` :::note The generator reports `Origin = "array"` for arrays and collections (a `List` is a JSON array) and `Origin = "record"` for dictionaries, matching `ZodArray`, `ZodRecord` and the null-element errors. ::: ## Collection null items A collection property rejects `null` elements by default: the generated validator reports `invalid_type` at the element's own path (`["Items", 0]`). Control it per property with `[CollectionBehavior(AllowNulls = true/false)]`, per element type with `T?`, per type with `[ZodSchema(AllowNulls = ...)]`, or per assembly with `[assembly: ZodSharpSchemaDefaults(AllowNulls = ...)]` and the `ZodSharpAllowNulls` MSBuild property. The most specific statement wins. A dictionary property (`IDictionary<,>`/`IReadOnlyDictionary<,>`) applies the same setting to its **values**, and reports a failure at the entry's key (`["Items", "a"]`). See [Collection null items](../source-generator/#collection-null-items) for the full precedence, and `ZODSGEN046` for the attribute-applicability warning. ## Range `[Range]` supports three constructor shapes plus `MinimumIsExclusive`, `MaximumIsExclusive`, `ConvertValueInInvariantCulture`, and `ParseLimitsInInvariantCulture`: - `[Range(int, int)]` and `[Range(double, double)]` — literal numeric bounds. - `[Range(typeof(T), "min", "max")]` — parsed bounds for numeric types and comparable types. Comparable range targets include `TimeSpan`, `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly`, and `Version` (which uses `CompareTo`), plus any type implementing `IComparable` with user-defined comparison operators. Bounds are emitted as static typed fields and compared without runtime attribute execution. A failure keeps the code `invalid_range` and reports `Category` `too_small` or `too_big` by direction, an origin from the value's Zod type (`number`, `date`, …), and both bounds as `Lower`/`Upper` (`ErrorBound`) with `MinimumIsExclusive`/`MaximumIsExclusive` reflected in each bound's `Inclusive`. ## IValidatableObject and asynchronous DataAnnotations A `[ZodSchema]` type that implements `IValidatableObject` is validated by its `Validate(ValidationContext)` after the members and type rules, and only when they all passed — the order DataAnnotations and Microsoft.Extensions.Validation use. Each `ValidationResult` becomes an error at its `MemberNames` (or the root), with code `validation_failed` (`ZodGeneratedValidation.DataAnnotationsErrorCode`) and category `custom`. ```csharp using System.ComponentModel.DataAnnotations; using ZodSharp; [ZodSchema] public class Stay : IValidatableObject { [Range(1, 30)] public int Nights { get; set; } = 1; public int Guests { get; set; } public IEnumerable Validate(ValidationContext validationContext) { if (Guests > Nights * 4) yield return new ValidationResult("Too many guests", [nameof(Guests)]); } } // Guests = 9 with Nights = 1 reports "Too many guests" at "Guests". ``` On .NET 11 the asynchronous DataAnnotations inputs run in `ValidateAsync`: - an `IAsyncValidatableObject` target is awaited after the members and type rules (when they passed); - an `AsyncValidationAttribute` on a member is awaited for that member, in `ValidateAsync` and in `ValidateMemberAsync`. These rules cannot run synchronously, so the generated synchronous `Validate` of such a type throws `InvalidOperationException` rather than skipping them, and the generator reports `ZODSGEN048`. Call `ValidateAsync` (Microsoft.Extensions.Validation, Blazor and ASP.NET Core do), and pass your services so the `ValidationContext` can resolve them. ## Error message customization Honours `ErrorMessage`, or `ErrorMessageResourceName` + `ErrorMessageResourceType`. Positional placeholders keep the attribute's own DataAnnotations meaning — `{0}` is the display name, and `{1}`/`{2}` are the values that attribute formats (`[Range]`: minimum, maximum; `[StringLength]`: maximum, minimum; `[Length]`: minimum, maximum, like `LengthAttribute`) — and named placeholders such as `{displayName}`, `{minimum}` and `{maximum}` work too. Messages are formatted at runtime by `ZodMessageFormat`, which never throws: an unknown placeholder or a stray brace is written out literally, and `{{`/`}}` are literal braces. The template is kept in `ValidationError.MessageFormat` and the values in `Parameters`, so a host can re-format the message in the request culture. A number `[Range]` reports its `minimum`/`maximum` parameters as numbers of the member's type, so ProblemDetails writes `"minimum": 1` rather than `"minimum": "1"`; a converted non-number range (dates, times) keeps the attribute's text for them, and its typed bounds are the error's `Lower`/`Upper`. A placeholder the attribute does not supply, or an unbalanced brace, is reported at build time as `ZODSGEN052`. Providing only one of the resource name/type pair is reported as ZODSGEN005. ```csharp [ZodSchema] public class Profile { [Length(2, 20, ErrorMessage = "{0} must have between {1} and {2} characters")] public string? Nickname { get; set; } } // "Nickname must have between 2 and 20 characters" ``` :::note In 3.0.0, `[Length]`'s custom message binds `{1}` to the minimum and `{2}` to the maximum, matching `LengthAttribute` (they were swapped), and a custom message containing `{1}` or a stray brace no longer fails the build or throws during validation. ::: ## Microsoft.Extensions.Validation and `[ValidatableType]` A `[ZodSchema]` type does not need `[ValidatableType]`: the ZodSharp bridge (`Purview.ZodSharp.Extensions.Validation`) resolves its generated validator first, and the attribute only makes the Microsoft.Extensions.Validation generator emit a second, DataAnnotations-only validator for the same type. The generator reports `ZODSGEN047` (info), with a code fix that removes the attribute. See [Microsoft.Extensions.Validation](../../hosting/microsoft-extensions-validation/). ## Type applicability diagnostics Misuse is reported at compile time rather than silently ignored: - `[Length]` with `min > max` → ZODSGEN003. - `[Length]` on an unsupported target (e.g. `decimal`) → ZODSGEN004. - String-only attributes (`[RegularExpression]`, `[EmailAddress]`, `[Url]`, `[Phone]`, `[CreditCard]`, `[Base64String]`) on non-string targets, `[AllowedValues]`/`[DeniedValues]` on unsupported types, or `[Range]` on unsupported types → ZODSGEN006. - `[Compare]` referencing an unknown property → ZODSGEN020. See [Source Generator Diagnostics](../source-generator-diagnostics/) for the full list. ## Custom attributes 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](../../schemas/custom-rules/). Every built-in rule also ships a generated attribute in the `ZodSharp.Rules` namespace, so it can be used directly alongside the attributes above: `[Email]`, `[E164]`, `[Ulid]`, `[Uuid]`, `[Jwt]`, `[IpAddress]`, `[Hex]`, `[Regex]`, `[StartsWith]`, `[EndsWith]`, `[Includes]`, `[MultipleOf]`, `[Finite]`, `[SafeInteger]`, `[Int]`, `[Uri]`, `[Base64Url]`, `[Nanoid]`, `[Cuid2]`, `[DateString]`, `[DatetimeString]`, `[TimeString]`, `[Emoji]`, `[Xid]`, `[Ksuid]`, `[Duration]`, `[Guid]`, `[Cidr]`, `[NonSentinel]`, `[NullOrNonWhiteSpace]`, `[MinValue]`, `[MaxValue]`, `[GreaterThan]`, `[LessThan]`, `[GreaterThanOrEqual]`, `[LessThanOrEqual]`, `[Even]`, `[Odd]`, `[Enum]`, `[MinCount]`, `[MaxCount]`, `[CountRange]`, `[RequiredZod]`, `[RangeZod]`, `[LengthZod]`, `[StringLengthZod]`, `[CompareZod]`, `[AllowedValuesZod]`, and `[DeniedValuesZod]`. A value the rule declares without a default is a required constructor argument — `[Regex("…")]`, `[UUID(UuidVersion.V4)]`, `[Uri(UriKind.Absolute)]`, `[MinValue(3)]` — while defaulted values and `Message`/`Code` stay named properties. A rule with overloaded constructors mirrors each overload, so `[Uuid]` uses the versionless UUID rule and `[UUID(UuidVersion.V4)]` the versioned one. `[UUID]` also applies to a `Guid` member or a `[Scalar]` type (a `Guid` is a UUID, so the rule validates its bytes), which is how a time-bound identifier that must be an RFC version is constrained. The rules whose name collides with a DataAnnotations attribute use a `Zod` suffix (`[MinLengthZod]`, `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`, `[RequiredZod]`, `[RangeZod]`, `[LengthZod]`, `[StringLengthZod]`, `[CompareZod]`, `[AllowedValuesZod]`, `[DeniedValuesZod]`). See [Built-in attributes](../../schemas/custom-rules/#built-in-attributes-shipped-with-purviewzodsharp). `[MinCount(n)]`, `[MaxCount(n)]` and `[CountRange(min, max)]` are the trim-safe spelling of `[MinLength]`, `[MaxLength]` and `[Length]` on a collection or dictionary member: the BCL attributes' constructors are `[RequiresUnreferencedCode]`, so they raise IL2026 in a trimmed or AOT application. The generator validates both spellings through the same code, with the same error identity, and `ZODSGEN053` suggests the swap in a trimmed project. See [Trimming and Native AOT](../../quality/trimming-and-aot/). --- # Source Generator Diagnostics The `[ZodSchema]` generator ships an analyzer (category `ZodSharp.SourceGenerator`) that reports configuration and usage problems at compile time. Every diagnostic below is enabled by default; `ZODSGEN033`, `ZODSGEN037`–`ZODSGEN040`, `ZODSGEN042`, `ZODSGEN045`, `ZODSGEN046`, `ZODSGEN048`, `ZODSGEN049`, `ZODSGEN051` and `ZODSGEN052` are warnings, `ZODSGEN041`, `ZODSGEN043` and `ZODSGEN047` are informational, and the rest are errors. | ID | Meaning | |---|---| | ZODSGEN001 | Unhandled generator exception (`"Source generator failed for {0}: {1}"`) | | ZODSGEN003 | Invalid `[Length]` configuration (min > max) | | ZODSGEN004 | Unsupported `[Length]` target | | ZODSGEN005 | Invalid DataAnnotations error-message resource configuration (name without type, or type without name) | | ZODSGEN006 | Unsupported DataAnnotations usage (string-only attributes on non-string targets; `[AllowedValues]`/`[DeniedValues]` on unsupported types; `[RegularExpression]` on non-strings; `[Range]` on unsupported types) | | ZODSGEN007 | Custom validation method configured but not found (when a name is explicitly configured) | | ZODSGEN008 | Custom method return type is not `ValueTask>` | | ZODSGEN009 | Custom method does not take two or three parameters: `(T value, CancellationToken ct)` or the service-aware `(T value, IServiceProvider services, CancellationToken ct)` | | ZODSGEN010 | First custom method parameter is not the model type | | ZODSGEN011 | Last custom method parameter is not `CancellationToken` | | ZODSGEN054 | A three-parameter (service-aware) custom method's second parameter is not `IServiceProvider` | | ZODSGEN012 | Custom method is generic | | ZODSGEN013 | Custom method must be static when defined on the model type | | ZODSGEN014 | Custom method is inaccessible from the generated validator (private/protected) | | ZODSGEN015 | Ambiguous custom method overloads (only when at least two valid candidates exist) | | ZODSGEN016 | Configured method name is not a valid C# identifier | | ZODSGEN017 | Custom method is abstract | | ZODSGEN018 | Custom method is an unimplemented partial method | | ZODSGEN019 | Custom method uses `ref`/`in`/`out`/`params`/`scoped` parameters | | ZODSGEN020 | `[Compare]` references an unknown property | | ZODSGEN021 | `System.ComponentModel.DataAnnotations` reference missing | | ZODSGEN027 | `IValidateOptions` requested but `Microsoft.Extensions.Options` reference is missing | | ZODSGEN028 | `IValidateOptions` requested on a struct (requires a class) | | ZODSGEN029 | A model declares both the `OnZodValidate` refinement hook and an async custom validation method (only one is allowed) | | ZODSGEN030 | A custom rule mapped through `[ZodRule(typeof(...))]` does not implement `IValidationRule` for the property type (including an unbound generic rule that cannot be closed with it) | | ZODSGEN031 | A custom rule constructor parameter could not be mapped from the attribute (`[ZodRule]`) | | ZODSGEN032 | A validation attribute could not be generated for a rule marked `[ZodRule]` | | 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 | | ZODSGEN034 | The `OnZodValidate` refinement hook is implemented on a type that is not `partial` (or whose containing types are not all `partial`), so the generated declaration cannot be emitted | | ZODSGEN035 | The `OnZodValidate` refinement hook is not declared as `partial void OnZodValidate(RefineCtx context)` (wrong modifiers, return type, or parameters) | | ZODSGEN036 | A member still uses the retired synchronous refinement contract (`IEnumerable Validate()`); implement `OnZodValidate` instead | | ZODSGEN037 | (warning) A rule marked `[ZodRule]` derives an attribute name (`XRule` → `XAttribute`) that a hand-authored type already declares, so no attribute is generated and that declaration's own `[ZodRule]` mapping governs every usage | | ZODSGEN038 | (warning) A hand-authored rule attribute's `[ZodRule(typeof(...))]` mapping does not address every rule declared under the name the attribute encodes (`XAttribute` → `XRule`), so some usages of the attribute resolve to no rule | | ZODSGEN039 | (warning) A rule accepts a `code`/`origin` constructor parameter but does not implement `IZodRule`, so the value never reaches the reported error identity | | ZODSGEN040 | (warning) An attribute argument has no effect: the resolved rule has no matching constructor parameter and the value is not part of the reported error identity | | ZODSGEN041 | (info) A typed union (`Z.Union`) whose option types are all reference types can use the allocation-free native C# 15 union returned by `Z.NativeUnion` on .NET 11+; a code fix is offered | | ZODSGEN042 | (warning) A validation rule (a type implementing `IValidationRule`) does not expose a public `const string ErrorCode` and a public `const string MessageFormat`, so its error identity cannot be asserted in tests without duplicating literals | | ZODSGEN043 | (info) A built-in rule marked `[ZodRule]` does not generate a validation attribute, because a constructor parameter cannot be represented as an attribute property; the rule is still reachable through the runtime/fluent API. A derived name that collides with `System.ComponentModel.DataAnnotations` is *not* skipped — the attribute is emitted under a `Zod` suffix instead | | ZODSGEN044 | A `[ZodSchema]` type that uses an automatic `[Scalar]` form (`[Scalar]` or `[Scalar(typeof(TValue))]`) cannot produce a usable schema, so the value-object generator's generated `Create` would reference a `{Type}Schema` that does not exist or cannot validate. Reported when `[ZodSchema(GenerateValidateMethod = false)]` suppresses the `Validate` method the value-object generator calls, or when the declared underlying type cannot be represented | | ZODSGEN045 | (warning) A type that exports JSON Schema references a nested `[ZodSchema]` type whose export is disabled (`[ZodSchema(GenerateJsonSchema = false)]` or the assembly-level `ZodSharpGenerateJsonSchemas=false` switch), so the parent's exported schema falls back to an empty schema for that property (`"Property '{0}' references type '{1}', which disables JSON Schema generation; the exported schema cannot describe it"`) | | ZODSGEN046 | (warning) A `[CollectionBehavior]` attribute has no applicable option: `AllowNulls` requires nullable elements/values; `DefaultImmutableArrayBehavior` applies to `ImmutableArray` (including nullable wrappers) | | ZODSGEN047 | (info) `[ValidatableType]` (Microsoft.Extensions.Validation) on a `[ZodSchema]` type is redundant: the ZodSharp bridge resolves the generated validator first, and the attribute only makes the Microsoft.Extensions.Validation generator emit a second, DataAnnotations-only validator for the same type. A code fix removes the attribute | | ZODSGEN048 | (warning) A type has rules that only run asynchronously (an `AsyncValidationAttribute` member or an `IAsyncValidatableObject` target) but a synchronous `Validate` is generated; that `Validate` throws `InvalidOperationException` rather than skip them, so call `ValidateAsync` | | ZODSGEN049 | (warning) A member is typed as a C# 15 `closed` class whose derived type has no `[ZodSchema]`, so a value of that derived type is not validated (`"Property '{0}' is typed as the closed type '{1}', but the derived type '{2}' has no [ZodSchema], ..."`); add `[ZodSchema]` to the derived type | | ZODSGEN050 | A generated `IValidateOptions` validator would conflict with a hand-authored `[OptionsValidator]` class (SYSLIB1205), so the ZodSharp validator is not generated. Keep one generator: remove `[OptionsValidator]`, or set `[ZodSchema(SuppressIValidateOptions = true)]` | | ZODSGEN051 | (warning) A `[ZodSchema]` type is declared in a Razor component (`.razor`). Razor compiles components with its own source generator, which the ZodSharp generator cannot see, so no schema is generated; move the type to a `.cs` file | | ZODSGEN052 | (warning) An attribute's `ErrorMessage` uses a positional placeholder the attribute does not supply, or an unbalanced brace (`"The ErrorMessage of [{0}] on '{1}' contains '{2}', which the attribute does not supply; it is shown literally"`). Messages are formatted with a never-throw engine, so the text is shown literally at runtime; the diagnostic catches the typo at build time | | ZODSGEN053 | (warning) `[MinLength]`, `[MaxLength]` or `[Length]` is applied to a collection member of a `[ZodSchema]` type in a project with `PublishTrimmed`, `PublishAot`, `IsTrimmable` or `IsAotCompatible` (`"[{0}] on the collection member '{1}' is not trim-safe (its constructor is [RequiresUnreferencedCode]); use [{2}] from ZodSharp.Rules, which the generator validates identically"`). The BCL attributes raise IL2026 on every usage; `[MinCount]`, `[MaxCount]` and `[CountRange]` report the same errors. A code fix swaps the attribute. See [Trimming and Native AOT](../../quality/trimming-and-aot/#collection-counts-mincount-maxcount-and-countrange) | IDs `ZODSGEN002` and `ZODSGEN022`–`ZODSGEN026` are intentionally unused; rule identifiers are never renumbered or re-used. The `[ErrorType]` generator, shipped in the same analyzer assembly, reports `ZODSASP001`–`ZODSASP003`, `ZODSASP100` and `ZODSASP101`; they are described in [ASP.NET Core Integration](../../aspnetcore-integration/). ## Suppressing Diagnostics can be suppressed per-project or per-site with the standard `#pragma warning disable ZODSGEN006` / `NoWarn` mechanisms. Refer to the analyzer's shipped release notes (`AnalyzerReleases.Shipped.md` / `AnalyzerReleases.Unshipped.md` in the generator project) for the canonical catalog. The package also ships one **suppression** that is not a reportable rule: `ZODSUP001` removes `CA1849` ("call async methods when in an async method") for ZodSharp's synchronous validation entry points — generated `Validate` and `ValidateMember`, the validator adapters and the fluent schemas. CA1849 fires there only because `ValidateAsync` exists; the synchronous pipeline is the cheap path and never blocks, and a type whose graph has asynchronous rules is already covered by `ZODSGEN048`. The suppression is by call target, so `Task.Wait`, `Thread.Sleep` and another library's `Validate`/`ValidateAsync` pair still report CA1849. --- # JSON Schema Export Export a Purview.ZodSharp schema to a JSON Schema (Draft 2020-12) definition with `Z.ToJsonSchema`, which lives in the core `Purview.ZodSharp` package. ```csharp using ZodSharp; var userSchema = Z.Object() .Field("name", Z.String().Min(3)) .Field("email", Z.String().Email()) .Field("age", Z.Number().Min(0).Int()) .Build(); var jsonSchema = Z.ToJsonSchema>(userSchema, new ToJsonSchemaOptions { Title = "User", Id = "https://example.com/schemas/user.json" }); ``` ## ToJsonSchemaOptions | Property | Default | Purpose | |---|---|---| | `IncludeSchema` | `true` | emit `$schema: "https://json-schema.org/draft/2020-12/schema"` | | `Id` | `null` | sets `$id` | | `Title` | `null` | sets `title` | ## JsonSchemaDefinition `Z.ToJsonSchema` returns `JsonSchemaDefinition` (namespace `ZodSharp.JsonSchema`), a mutable POCO mirroring the JSON Schema keywords: - **Identity**: `Schema` (`$schema`), `Id` (`$id`), `Ref` (`$ref`). - **Type/title**: `Type`, `Title`, `Description`, `Default`, `Format`. - **String**: `MinLength`, `MaxLength`, `Pattern`. - **Number**: `Minimum`, `Maximum`, `ExclusiveMinimum`, `ExclusiveMaximum`, `MultipleOf`. - **Array**: `Items`, `MinItems`, `MaxItems`, `UniqueItems`. - **Object**: `Properties`, `Required`, `AdditionalProperties`. - **Composition**: `AnyOf`, `OneOf`, `AllOf`. - **Enum/const**: `Enum`, `Const`. - **Definitions**: `Defs` (2020-12) and `Definitions` (draft-07). - **Metadata**: `Deprecated`, `ReadOnly`, `WriteOnly`, `Examples`, `Nullable` (OpenAPI 3.0). ## Supported schema types `ToJsonSchemaConverter` handles `ZodString`, `ZodNumber`, `ZodBoolean`, `ZodNull`, `ZodObject`, `ZodOptional`, `ZodUnion`, `ZodArray`, `ZodRecord`, `ZodLiteral`, `ZodNullable`, `ZodNativeEnum`/`ZodEnum`, `ZodDiscriminatedUnion` and `ZodLazy`. - Objects emit `additionalProperties: false`. - Records (`Z.Record(valueSchema)`) emit `{ "type": "object", "additionalProperties": }`. - Literals emit `const` (and `type`). - Native enums emit `enum` with the defined members in their System.Text.Json wire form: the numeric values by default, or the member names when the enum is serialized as a string. A `[Flags]` enum emits `type: integer` instead, because a combination is valid without being a defined member. - Discriminated unions emit `oneOf` with one branch per option (they used to export the empty schema). The discriminator property is recorded as the `x-zod-discriminator` vendor extension and each branch records its value as `x-zod-discriminatorValue` (see [Vendor extensions](https://github.com/purview-dev/zodsharp/blob/main/docs/wiki/serialization#vendor-extensions)). - Lazy/recursive schemas emit `$ref` entries under `$defs` (e.g. `#/$defs/__lazyN`). Rules that describe their own JSON Schema constraint implement `IJsonSchemaRule` and are applied during export (see [Validation rules and JSON Schema](https://github.com/purview-dev/zodsharp/blob/main/docs/wiki/serialization#validation-rules-and-json-schema) below). ## Schemas known only at runtime `ToJsonSchemaConverter.ConvertUntyped(object schema, ToJsonSchemaOptions? options = null)` (namespace `ZodSharp.JsonSchema`) converts a schema whose type is only known at runtime: a ZodSharp schema, a generated validator (`IJsonSchemaProvider`), or an adapter returned by `IZodSchemaFactory.TryGetUntypedValidator` (`ZodSchemaValidator` and `ZodUntypedValidator` are unwrapped). A value the exporter cannot describe produces the empty (`any`) schema. ```csharp using ZodSharp.JsonSchema; if (factory.TryGetUntypedValidator(typeof(Order), out var validator)) { var definition = ToJsonSchemaConverter.ConvertUntyped(validator!); } ``` This is the entry point the OpenAPI transformers use; see [OpenAPI Integration](../../hosting/openapi-integration/). ## Export a generated `[ZodSchema]` A source-generated `[ZodSchema]` schema can be exported directly — there is no need to rebuild its rules by hand with `Z.Object()`/`Z.String()`. The generated `{Type}Schema` class exposes a static `BuildJsonSchema()` method, and the generated `{Type}SchemaValidator` (the DI adapter) implements `IJsonSchemaProvider`, so it can be handed to `Z.ToJsonSchema`: ```csharp using ZodSharp; // Either call the generated schema directly… var jsonSchema = RepositoryInventoryItemSchema.BuildJsonSchema(); // …or export the generated validator (the DI adapter) through Z.ToJsonSchema. var exported = Z.ToJsonSchema(new RepositoryInventoryItemSchemaValidator()); ``` The definition is produced from the same metadata the generated validator uses, so the two cannot drift apart: property names, nested objects, native enums, nullable fields, required fields, `[NonSentinel]`, `[NullOrNonWhiteSpace]`, DataAnnotations, and `[ZodRule]`-mapped rules are all reflected. - **Property names** use the System.Text.Json wire form: an explicit `[JsonPropertyName]` wins, otherwise the name is camelCased to match the ASP.NET Core / `JsonSerializerDefaults.Web` default. A non-camelCase global naming policy configured at runtime is not visible to the generator, so a consumer using one must adjust the exported names (or declare `[JsonPropertyName]`). - **Required fields** follow the nullable reference-type annotation: a non-nullable reference type (or `Nullable`) is required, and a nullable one (`string?`) is wrapped in `anyOf: [ …, { "type": "null" } ]` and is not required. `[Required]` also makes a field required. The generated validator agrees: a non-nullable reference type rejects null and its rules run unconditionally, while a nullable one skips them for null. A rule that rejects an absent value (for example `[RequiredZod]`) makes the field non-null even when its annotation is nullable. - **Collection items** follow the null-items setting (see [Collection null items](../../generator/source-generator/#collection-null-items)): a collection whose elements may be null exports `items` wrapped in `anyOf: [ …, { "type": "null" } ]`; otherwise `items` is the element's own schema. - **Scalar value objects** (`[Scalar]`) export as their underlying value's schema — a `Guid` scalar is a canonical 8-4-4-4-12 hexadecimal string (a `pattern`, not Zod's RFC `uuid` format, because C# `Guid` accepts any 128-bit value) — not as an object wrapping a `Value` property. - **Enums** follow System.Text.Json. They export as numbers by default; a per-type or per-property `[JsonConverter(typeof(JsonStringEnumConverter))]` (or the assembly-level `ZodSharpEnumSerialization=String` switch, matching a global converter) makes them strings, and `ZodSharpEnumNamingPolicy=CamelCase` matches a `JsonNamingPolicy.CamelCase`. A member's `[JsonStringEnumMemberName]` name is used verbatim. A `[Flags]` enum exports as `type: integer`. - **Unsupported rules** are not dropped silently: a rule the exporter cannot represent is listed under the `x-zodsharp-unsupported` extension so the exported schema is visibly incomplete. > `additionalProperties: false` is emitted for objects (matching the runtime exporter). System.Text.Json > ignores unknown members by default, so a request with extra keys is accepted by the C# validator but > rejected by the exported schema. ### Opting a type out of schema generation Not every `[ZodSchema]` type belongs to the cross-platform contract surface — a validation-only domain or options type does not need to be exported to TypeScript/JavaScript. Two switches disable the export members (the static `BuildJsonSchema` and the `IJsonSchemaProvider` implementation) while keeping validation intact: ```csharp [ZodSchema(GenerateJsonSchema = false)] // this type only public sealed record InternalThing; ``` ```xml false ``` A type that exports JSON Schema must not reference a type whose export is disabled, because the nested `BuildJsonSchema` does not exist. The generator falls back to an empty (any) schema for that property and the analyzer reports **ZODSGEN045** naming the property and the disabled type, so the gap is surfaced at build time rather than shipped silently. ## Dictionaries System.Text.Json writes a dictionary as a JSON object, so a generated dictionary property (`IDictionary` / `IReadOnlyDictionary`) exports an object rather than an array: ```json "variants": { "type": "object", "additionalProperties": { "type": "object", "properties": { … } } } ``` - `additionalProperties` is the **value** type's schema. A nested `[ZodSchema]` value type is referenced inline, exactly as an element schema is for an array; a type whose export is disabled falls back to `{}` and reports `ZODSGEN045`. - A nullable value (`Dictionary`, `Dictionary`, or `[CollectionBehavior(AllowNulls = true)]`) wraps the value schema in `anyOf: [ …, { "type": "null" } ]`, mirroring the sequence `items` branch. - `[Length]`/`[MinLength]`/`[MaxLength]` export `minProperties`/`maxProperties` (not `minItems`/`maxItems`). - The TypeScript counterpart is `z.record(z.string(), V)`. Importing an object with `additionalProperties` produces a record schema — see [JSON Schema Import](../jsonschema-import/#records). ## Validation rules and JSON Schema Every rule that can describe its own JSON Schema constraint implements `ZodSharp.JsonSchema.IJsonSchemaRule`. The exporter applies these rules through `JsonSchemaRules.Apply`, and the generated `BuildJsonSchema()` does the same, so a rule maps to JSON Schema identically on both paths: | Rule | JSON Schema | |---|---| | `MinLengthRule` / `MaxLengthRule` | `minLength` / `maxLength` | | `MinValueRule` / `MaxValueRule` | `minimum` / `maximum` | | `GreaterThanRule` / `LessThanRule` | `exclusiveMinimum` / `exclusiveMaximum` | | `MultipleOfRule` / `EvenRule` / `OddRule` | `multipleOf` (odd uses `not: { multipleOf: 2 }`) | | `IntRule` / `SafeIntegerRule` | `type: integer` (safe integer also bounds to the `int` range) | | `EmailRule` / `UrlRule` / `UriRule` | `format: email` / `uri` | | `IPAddressRule` (IPv4 / IPv6) | `format: ipv4` / `ipv6` | | `DurationRule` | `format: duration` | | `DateStringRule` | `format: date` | | `DatetimeStringRule` | `format: date-time` plus its UTC `pattern` | | `TimeStringRule` | a local-time `pattern` | | `Base64StringRule` | `contentEncoding: base64` | | `CidrRule` / `JWTRule` / `IPAddressRule` (any version) | listed as unsupported; record `x-zod-cidr` / `x-zod-jwt` / `x-zod-ipVersion` | | `UUIDRule` (versionless) | `format: uuid` | | `UUIDRule(UuidVersion.Vx)` | `format: uuid` plus a `pattern` that pins the version and variant | | `GuidRule` | a `pattern` for a canonical 8-4-4-4-12 hexadecimal identifier (any version/variant) | | `RegexRule` | `pattern` | | `StartsWithRule` / `EndsWithRule` / `IncludesRule` | anchored / escaped `pattern` | | `NonSentinelRule` / `NullOrNonWhiteSpaceRule` | `minLength: 1` + `pattern: "\\S"` | | `NonSentinelRule` | a `pattern` that rejects the all-zero UUID | | `NonSentinelRule` | a `pattern` that rejects `0001-01-01` and `9999-12-31` | | `NonSentinelRule` | a `pattern` that rejects `00:00:00` and `23:59:59.9999999` | | `NonSentinelRule` | a `pattern` that rejects the deterministic min/max timestamp forms (no offset, `Z`, or zero offset) | | `NonSentinelRule` | an offset-aware `pattern` that rejects every RFC 3339 representation of both instant bounds; the `Utc` contract narrows it to zero-offset form | | `AllowedValuesRule` / `DeniedValuesRule` / `EnumRule` | `enum` / `not: { enum: … }` | | `FiniteRule` / `RequiredRule` | no keyword (implied by `number` / the object's `required` list) | A C# `Guid` is exported as a `pattern`, not `format: uuid`, because `Guid` accepts any 128-bit value while an RFC UUID validator (Zod's `z.uuid()`, which `z.fromJSONSchema` applies to **both** `uuid` and `guid`) checks the version and variant nibbles. A time-bound identifier generated by `FastGuid.NewSqlServerGuid()` / `NewPostgreSqlGuid()` is deliberately not an RFC UUID and would be rejected by that validator. A Guid that must be a specific RFC version opts in with `[UUID(UuidVersion.Vx)]` (which also validates a `Guid` or a `[Scalar]`), and exports `format: uuid` plus a version-pinning `pattern`. JSON Schema has a single `pattern` keyword, so when more than one rule constrains the pattern (for example a Guid plus `[NonSentinel]`, or a regex plus `[NonSentinel]`) the constraints are composed under `allOf` rather than one overwriting the other. Each `allOf` branch repeats the schema's `type`, because Zod's `fromJSONSchema` discards a branch without one. A rule that does not implement `IJsonSchemaRule` is reported through `JsonSchemaDefinition.UnsupportedRules` (`x-zodsharp-unsupported`). Consumer rules opt in by implementing the interface; for example a custom null-or-non-whitespace rule would add `IJsonSchemaRule` and set `minLength`/`pattern` on the schema passed to `ApplyTo`. The built-in `NullOrNonWhiteSpaceRule` already does this, so a domain that needs that behaviour should use `[NullOrNonWhiteSpace]` rather than a bespoke rule. ## Vendor extensions Some rules enforce something JSON Schema has no keyword for, or that a keyword only approximates (the literal behind a `startsWith` pattern, a UUID version, a CIDR family, a discriminator). The exporter records that information as `x-zod-*` vendor extensions on the definition: ```csharp IReadOnlyDictionary extensions = definition.GetVendorExtensions(); definition.SetVendorExtension("x-zod-note", "custom"); // the name must start with "x-" ``` They are exposed through methods rather than a property on purpose: the JSON Schema serializers write properties only, so the exported JSON Schema is unchanged. Hosts whose format carries vendor extensions read them - the OpenAPI transformers write them into the document when `ZodOpenApiOptions.EmitVendorExtensions` is on (see [OpenAPI Integration](../../hosting/openapi-integration/)). ## Temporal sentinels and the DateTimeOffset wire form `DateOnly`, `TimeOnly` and `DateTime` export a `pattern` that rejects the System.Text.Json wire forms of their `MinValue`/`MaxValue`. The temporal mappings are exact for the deterministic forms: a `DateOnly` has one `yyyy-MM-dd` form, `TimeOnly` is a local `HH:mm:ss[.fffffff]` value, and a `DateTime` is deterministic when it is `Unspecified` or `Utc` (or written with a zero offset). A `DateTimeKind.Local` boundary value can carry an arbitrary machine offset, and JSON Schema has no way to know the receiver's time zone; use `Unspecified`/`Utc`, or `DateTimeOffset`/`Z.DateTimeOffset()` whose pattern is exact, for a portable contract. `TimeOnly` is exported as a `pattern` rather than `format: time`. JSON Schema's `format: time` is an RFC 3339 full-time and requires an offset, while System.Text.Json never writes one for `TimeOnly`; a strict `format: time` consumer would reject every value. `DateTimeOffset` compares instants, and RFC 3339 allows the same instant at different offsets (`0001-01-01T00:00:00+00:00` and `0001-01-01T05:30:00+05:30` are both `DateTimeOffset.MinValue`). The default `DateTimeOffsetSerialization.PreserveOffset` pattern is offset-aware: it correlates the local time with the offset and rejects every representation of both bounds. It is exact, so no `x-zodsharp-unsupported` marker is emitted and no serializer change is required. The pattern is intentionally detailed because it encodes that relation for both bounds; it stays a standard ECMA-262 `pattern`. The `Utc` setting is a narrower contract. It requires a `Z` or zero offset and pairs with a converter that rejects non-UTC input, which is useful when the application wants to standardise on UTC: ```csharp using ZodSharp; // Project-wide default (or set ZodSharpDateTimeOffsetSerialization=Utc in Directory.Build.props). [assembly: ZodSharpSchemaDefaults( DateTimeOffsetSerialization = DateTimeOffsetSerialization.Utc)] ``` ```csharp // System.Text.Json using ZodSharp.Json.Serialization.SystemTextJson; options.Converters.Add(new UtcDateTimeOffsetConverter()); // Newtonsoft.Json — the helper also sets DateParseHandling.None so the converter sees the raw offset. using ZodSharp.Json.Serialization.NewtonsoftJson; var settings = new JsonSerializerSettings().UseZodSharpUtcDateTimeOffset(); ``` Under the UTC contract the exported `pattern` requires a `Z` or zero offset and rejects both bounds. The converter must reject a non-UTC offset on read; normalizing it silently would reintroduce the gap. A value such as `2024-01-02T03:04:05+05:30` is therefore rejected by both sides when the contract is UTC, even though it is a valid timestamp. Under the default `PreserveOffset`, that value is accepted and the exact offset-aware pattern still rejects the bounds. The setting can be overridden at several levels; the most specific statement wins: | Level | Declaration | |---|---| | Property (or positional record parameter) | `[DateTimeOffsetBehavior(DateTimeOffsetSerialization.Utc)]` / `...[PreserveOffset]` | | Containing type | `[DateTimeOffsetBehavior(DateTimeOffsetSerialization.Utc)]` | | Assembly | `[assembly: ZodSharpSchemaDefaults(DateTimeOffsetSerialization = ...)]` | | MSBuild | `ZodSharpDateTimeOffsetSerialization` (`Utc` or `PreserveOffset`, for example in `Directory.Build.props`) | | Built-in default | `PreserveOffset` | The attribute controls schema generation only. Pair a `Utc` override with `UtcDateTimeOffsetConverter`, and a `PreserveOffset` override under a project-wide UTC converter with `PreserveOffsetDateTimeOffsetConverter` (available in both integration packages). A `[Scalar]` owns its schema, so apply the attribute to the scalar type rather than to a property whose type is that scalar. A hand-built fluent schema has no compile-time metadata to read, so set the runtime equivalent on `ToJsonSchemaOptions` when converting a manual schema: ```csharp var jsonSchema = Z.ToJsonSchema( Z.DateTimeOffset().NonSentinel(), new ToJsonSchemaOptions { DateTimeOffsetSerialization = DateTimeOffsetSerialization.Utc }); ``` The default remains `PreserveOffset`, which is also exact. The `rule.ApplyTo(schema, ...)` and `JsonSchemaRules.Apply(schema, rule, ...)` overloads are available for code that builds a definition directly. ## Serialize the definition Pick the JSON serializer that matches the integration package you referenced: ```csharp // System.Text.Json (Purview.ZodSharp.SystemTextJson) using ZodSharp.JsonSchema.SystemTextJson; var json = System.Text.Json.JsonSerializer.Serialize(jsonSchema, JsonSchemaSerializerOptions.Default); // Newtonsoft.Json (Purview.ZodSharp.NewtonsoftJson) using ZodSharp.JsonSchema.NewtonsoftJson; var json = JsonConvert.SerializeObject(jsonSchema, JsonSchemaSerializerOptions.Default); ``` `JsonSchemaSerializerOptions.Default` (camelCase, ignore nulls, indented) and `.Reading` (camelCase, ignore nulls) are provided by each integration package and map the JSON Schema keyword names (`$schema`, `$id`, `$ref`, `$defs`), with camelCase for every other keyword. `JsonSchemaDefinition` itself carries no serializer annotations, so use these options when serializing it — that also keeps exported definitions round-tripping through the matching import API. ## Round-trip Import the exported definition back into Purview.ZodSharp with `Z.FromJsonSchema` from an integration package — see [JSON Schema Import](../jsonschema-import/) and the round-trip example in the example app (`JsonSchemaExamples`). --- # JSON Schema Import Import a JSON Schema into a Purview.ZodSharp schema with `Z.FromJsonSchema`. This API is provided by the JSON integration packages — reference either `Purview.ZodSharp.SystemTextJson` or `Purview.ZodSharp.NewtonsoftJson`. The two JSON integrations are mutually exclusive; pick one and import its JSON Schema namespace (`ZodSharp.JsonSchema.SystemTextJson` or `ZodSharp.JsonSchema.NewtonsoftJson`). ```csharp using ZodSharp; using ZodSharp.JsonSchema.SystemTextJson; // or ZodSharp.JsonSchema.NewtonsoftJson var jsonSchemaString = """ { "type": "object", "properties": { "name": { "type": "string", "minLength": 3 }, "email": { "type": "string", "format": "email" } }, "required": ["name", "email"] } """; var userSchema = Z.FromJsonSchema(jsonSchemaString); var result = userSchema.Validate(userData); ``` ## Overloads | Signature | Notes | |---|---| | `IZodSchema FromJsonSchema(string jsonSchema)` | parses the JSON string into a `JsonSchemaDefinition`, then into a schema | | `IZodSchema FromJsonSchema(JsonSchemaDefinition schema)` | import from an already-deserialized definition | :::note `Z.FromJsonSchema` is implemented as a C# 14 extension member on `Z`, so it only exists when a JSON integration package is referenced. `Z.ToJsonSchema` is a real static member on `Z` in the core package. ::: ## Supported keywords `FromJsonSchemaParser` (namespace `ZodSharp.JsonSchema.SystemTextJson` or `ZodSharp.JsonSchema.NewtonsoftJson`) maps: - `type` — `string` / `number` / `integer` / `boolean` / `null` / `object` / `array`. - `enum` → `ZodUnion` of literals (a single member becomes a literal); `const` → literal. - `anyOf` / `oneOf` → `ZodUnion`; `allOf` → the intersection of every branch (and the base schema when the schema also declares a `type`). A type-less branch is `any`, matching Zod's `fromJSONSchema`. - String constraints — `minLength`, `maxLength`, `pattern`, and `format` (`email`, `uri`, `uuid`). The `uuid`/`guid` format maps to the versionless `.UUID()`; a versioned `.UUID(UuidVersion.V7)` schema exports `format: "uuid"` plus a version-pinning `pattern`, and the `allOf` composition preserves that pattern on import. Multiple `pattern` constraints are composed under `allOf`, and every branch is applied. - Numeric constraints — `minimum`, `maximum`, `multipleOf`; `integer` additionally applies `.Int()`. - Objects — `required` and optional fields via `Z.Object().Field(...)`. - Arrays — `items`, `minItems`, `maxItems`. A nullable item schema (`items.anyOf` containing a `{"type":"null"}` branch) imports as a nullable element, matching `z.array(T.nullable())` and the shape the generated exporter emits for an allow-null collection. - Records — an object with no `properties` and an `additionalProperties` schema imports as a record: every member is validated by the value schema, and a failure is reported under a typed key segment for the member (`FullPath.ToDottedString()` renders `[a]`; the legacy `Path` is `["a"]`). `additionalProperties: true` treats every member as `any`; `false` (and an absent value) keeps the object contract. ## Records ```csharp var schema = Z.FromJsonSchema( """ { "type": "object", "additionalProperties": { "type": "string", "minLength": 1 } } """ ); schema.Validate(new Dictionary { ["a"] = "x" }); // Success schema.Validate(new Dictionary { ["a"] = "" }); // Failure at [a] (key segment) ``` This is the shape the generated exporter writes for a dictionary property, so a generated dictionary schema round-trips through `Z.FromJsonSchema` in either integration package. The result is a record schema (the `Z.Record` shape), not an object with fixed properties. ## Failures Imported schemas report the same structured errors as the equivalent fluent schemas: - **Type mismatches** describe JSON kinds with Zod's type names - `"Expected string, but got number"` - and never name `JsonElement`, `JToken` or another CLR type. The `expected`/`received` parameters carry the same names. - **`minItems`/`maxItems`** report exactly what `ZodArray` reports: `"Array must have at least 2 elements, but got 1"`, origin `array`, both bounds with their inclusivity, and category `too_small`/`too_big`. - **Literal mismatches** (`const`, single-member `enum`) name the expected literal and only the *kind* of the received value; the value itself is not echoed. - **Array elements and record values** are re-pathed with typed index and key segments (`[1]`, `[a]`), keeping every field of the inner error (origin, bounds, category, parameters). ## Limitations - `$ref` is supported only for **local** references (`#/...`, for example `#/$defs/Name`). External `$ref` targets throw `NotSupportedException` naming the unsupported reference: ```text External $ref 'external.json#/$defs/name' is not supported. Only local references ('#/...', for example '#/$defs/Name') can be resolved; inline or pre-resolve external schemas before importing. ``` Inline the referenced schema, or move it under the root `$defs`, before importing. Local references may be cyclic — a reference that is still being resolved becomes a lazy schema. - The reader binds the JSON Schema keyword names `$schema`, `$id`, `$ref`, and `$defs` (plus the draft-07 `definitions`) through the integration package's `JsonSchemaSerializerOptions`, and the same options write them back, so exported definitions round-trip through either package. `JsonSchemaDefinition` itself carries no serializer annotations, so serialize it with `JsonSchemaSerializerOptions` to get keyword-compliant output. ## Cross-platform reuse Export a TypeScript/Zod schema to JSON Schema (Zod v4+ `z.toJSONSchema`) and import it on the backend: ```typescript import { z } from "zod"; const UserSchema = z.object({ username: z.string().min(3), email: z.string().email() }); const jsonSchema = z.toJSONSchema(UserSchema); ``` ```csharp var userSchema = Z.FromJsonSchema(jsonSchemaString); var result = userSchema.Validate(incomingData); ``` See [Cross-Platform Interop](../../quality/cross-platform-interop/) for the repository's fixture-based verification of this loop. --- # System.Text.Json Integration The `Purview.ZodSharp.SystemTextJson` package adds System.Text.Json deserialize-and-validate, validating converters, and JSON Schema import to the core library. The deserialize/serialize extension methods live in the `ZodSharp` namespace; the JSON Schema import types (and `Z.FromJsonSchema`) live in the `ZodSharp.JsonSchema.SystemTextJson` namespace. ## Install ```bash dotnet add package Purview.ZodSharp.SystemTextJson ``` ## Deserialize and validate ```csharp using ZodSharp; var userSchema = Z.Object() .Field("name", Z.String().Min(3)) .Field("age", Z.Number().Min(0).Int()) .Build(); var json = """{ "name": "John", "age": 30 }"""; var result = userSchema.DeserializeAndValidate(json); if (result.IsSuccess) Console.WriteLine($"Valid: {result.Value}"); ``` Async stream overload: ```csharp await using var stream = File.OpenRead("user.json"); var result = await userSchema.DeserializeAndValidateAsync(stream); ``` ## Validate and serialize ```csharp var result = userSchema.ValidateAndSerialize(user); // ValidationResult var result2 = await userSchema.ValidateAndSerializeAsync(user, stream); ``` ## Validating converter ```csharp var converter = userSchema.CreateValidatingConverter(); var options = new JsonSerializerOptions { Converters = { converter } }; var value = JsonSerializer.Deserialize(json, options); ``` `CreateValidatingConverter()` returns a `System.Text.Json.Serialization.JsonConverter`. When the JSON is invalid, deserialization throws `ZodJsonValidationException` (namespace `ZodSharp.Json`, a `JsonException`) with a `"Validation failed: ..."` message, so existing `catch (JsonException)` blocks keep working. Its `Errors` property (`ImmutableArray`) holds the full errors - code, category, origin, bounds and parameters - located under the converted value's JSON path: a nested `Owner` whose `Name` failed reports `Owner.Name`, including when validating converters are nested. `DeserializeAndValidate` unwraps the exception and returns those errors. The converter strips itself from the options it uses internally to avoid recursion. ```csharp try { var value = JsonSerializer.Deserialize(json, options); } catch (ZodJsonValidationException ex) { foreach (var error in ex.Errors) Console.WriteLine($"{error.FullPath.ToDottedString()}: {error.Message} ({error.Code})"); } ``` ## API surface | Member | Signature | |---|---| | `DeserializeAndValidate` | `ValidationResult DeserializeAndValidate(this IZodSchema schema, string json, JsonSerializerOptions? options = null)` | | `DeserializeAndValidateAsync` | `ValueTask> DeserializeAndValidateAsync(this IZodSchema schema, Stream jsonStream, JsonSerializerOptions? options = null, CancellationToken cancellationToken = default)` | | `ValidateAndSerialize` | `ValidationResult ValidateAndSerialize(this IZodSchema schema, T value, JsonSerializerOptions? options = null)` | | `ValidateAndSerializeAsync` | `ValueTask> ValidateAndSerializeAsync(this IZodSchema schema, T value, Stream output, JsonSerializerOptions? options = null, CancellationToken cancellationToken = default)` | | `CreateValidatingConverter` | `JsonConverter CreateValidatingConverter(this IZodSchema schema)` | Each method also has a trim- and AOT-safe overload that takes a `JsonTypeInfo` in place of the options. The two async methods have a service-aware overload on `IZodSchemaValidator` (a generated `{TypeName}SchemaValidator`), so rules that resolve services run: | Member | Signature | |---|---| | `DeserializeAndValidateAsync` | `ValueTask> DeserializeAndValidateAsync(this IZodSchemaValidator validator, Stream jsonStream, JsonTypeInfo jsonTypeInfo, IServiceProvider? services, CancellationToken cancellationToken = default)` | | `ValidateAndSerializeAsync` | `ValueTask> ValidateAndSerializeAsync(this IZodSchemaValidator validator, T value, Stream output, JsonTypeInfo jsonTypeInfo, IServiceProvider? services, CancellationToken cancellationToken = default)` | The package is browser-safe (Blazor WebAssembly). For `HttpClient` bodies and server validation problems, see [HttpClient Integration](../../hosting/http-integration/). ## Failure codes A failure to read the JSON (malformed JSON, a value that cannot be converted, a `null` document, or a type the serializer cannot handle — `JsonException`, `NotSupportedException`, `InvalidOperationException`) produces one `ValidationError` with code `invalid_json` (`SystemTextJsonExtensions.InvalidJsonCode`) and category `invalid_type`. Its `FullPath` is the location the serializer reported (`$.items[1].age` becomes `items[1].age`), its `line` and `position` parameters hold the serializer's zero-based line number and byte position in the line, and its message is the fixed `"The JSON value could not be converted to {expected}."`, where `expected` is a Zod type name (`string`, `number`, `object`, `array`, …) found by walking the failing path through the serializer contract, or the generic `"the expected type"` when it cannot be described (for example malformed JSON read into a `Dictionary`). The serializer's own message, which names CLR types and can quote the input, is never surfaced. A failure raised by a validating converter is returned as the schema's own errors, located under the converter's path. How precise the location is depends on what failed, and is exactly what System.Text.Json reports: | Failure | `FullPath` for `{"items":[{"age":1},{"age":…}]}` | |---|---| | A value that cannot be converted (`"age":"x"`, `"age":true`) | `items[1].age` | | A syntax error while reading a value (`"age":}`, `"age":12x`) | `items[1].age` | | A syntax error between members (`"age":1,}`, a missing `:`) | `items[1]` - the element being read | | Truncated JSON | the next element (`items[2]`) | | Any failure inside a value read by a validating converter (`CreateValidatingConverter`) | a conversion failure keeps its full path (`items[1].age`); a syntax error is reported at the converted value (`items[1]`), because the converter - and, for a stream, the serializer's read-ahead - buffers the whole value first | `expected` is found for members, array and list elements and dictionary values on every target framework, and binary members (`byte[]`, `Memory`, base64 in JSON) are `string`. ```csharp var result = userSchema.DeserializeAndValidate("""{ "name": "John", "age": }"""); var error = result.Errors[0]; // error.Code == SystemTextJsonExtensions.InvalidJsonCode ("invalid_json") // error.Category == ZodIssueCategories.InvalidType ("invalid_type") // error.MessageFormat == "The JSON value could not be converted to {expected}." // error.Parameters["line"], ["position"] - where the serializer stopped ``` ### Values rejected by a strict converter A converter that rejects a value with ZodSharp errors - a Purview.ValueObjects strict converter throws `new JsonException(message, zodException)` - is not a failure to read JSON. `DeserializeAndValidate*` (and the Http package's `ReadFromJsonAndValidateAsync`) return those errors under the member that held the value, with JSON names mapped back to member names through the supplied `JsonTypeInfo`, and an error at the value object's own `Value` named after that member: ```csharp // {"customerEmail":"not-an-email","quantity":5} var result = await validator.DeserializeAndValidateAsync(stream, AppJsonContext.Default.PlaceOrderRequest); // result.Errors[0].FullPath -> CustomerEmail.Value // result.Errors[0].Message -> "Field 'CustomerEmail' must be a valid email address." ``` The errors are exactly those validating the hydrated model reports. `ZodJsonExceptions` (core package) exposes the same re-rooting for any exception (`TryGetErrors`, `TryReroot`, `Reroot`, `ToMemberPath`). A bare `ZodException` thrown from a converter still propagates unchanged. These are the same errors ASP.NET Core hosts project into ProblemDetails (see [ASP.NET Core Integration](../../aspnetcore-integration/)); OpenAPI documents describe the response shape (see [OpenAPI Integration](../../hosting/openapi-integration/)). ## JSON Schema import ```csharp using ZodSharp; using ZodSharp.JsonSchema.SystemTextJson; var schema = Z.FromJsonSchema(jsonSchemaString); ``` See [JSON Schema Import](../jsonschema-import/) for the supported keywords, `$ref` handling, and the `JsonSchemaSerializerOptions` defaults. ## Comparing with Newtonsoft See the API comparison table on the [Newtonsoft.Json Integration](../newtonsoftjson-integration/) page for the differences between the two JSON packages. Both packages can be referenced from one project. Their extensions share the `ZodSharp` namespace and names, so this package also has option-less overloads (`DeserializeAndValidate(json)`, `DeserializeAndValidateAsync(stream)` and `(stream, cancellationToken)`, `ValidateAndSerialize(value)`, `ValidateAndSerializeAsync(value, output)` and `(value, output, cancellationToken)`): a call without options resolves to System.Text.Json instead of failing with CS0121. `CreateValidatingConverter(schema)` is the one call that stays ambiguous; call `SystemTextJsonExtensions.CreateValidatingConverter(schema)` directly. --- # Newtonsoft.Json Integration The `Purview.ZodSharp.NewtonsoftJson` package adds Newtonsoft.Json deserialize-and-validate, validating converters, and JSON Schema import to the core library. The deserialize/serialize extension methods live in the `ZodSharp` namespace; the JSON Schema import types (and `Z.FromJsonSchema`) live in the `ZodSharp.JsonSchema.NewtonsoftJson` namespace. ## Install ```bash dotnet add package Purview.ZodSharp.NewtonsoftJson ``` ## Deserialize and validate ```csharp using ZodSharp; var userSchema = Z.Object() .Field("name", Z.String().Min(3)) .Field("age", Z.Number().Min(0).Int()) .Build(); var json = """{ "name": "John", "age": 30 }"""; var result = userSchema.DeserializeAndValidate(json); if (result.IsSuccess) Console.WriteLine($"Valid: {result.Value}"); ``` Async stream and `JToken` overloads: ```csharp await using var stream = File.OpenRead("user.json"); var result = await userSchema.DeserializeAndValidateAsync(stream); var jToken = JObject.Parse(json); var result2 = userSchema.DeserializeAndValidate(jToken); ``` ## Validate and serialize ```csharp var result = userSchema.ValidateAndSerialize(user); // ValidationResult var result2 = await userSchema.ValidateAndSerializeAsync(user, stream, formatting: Formatting.Indented); ``` ## Validating converter ```csharp var converter = userSchema.CreateValidatingConverter(); var value = JsonConvert.DeserializeObject(json, converter); ``` `CreateValidatingConverter()` returns a non-generic `Newtonsoft.Json.JsonConverter`. Invalid JSON throws `ZodNewtonsoftJsonValidationException` (namespace `ZodSharp.Json`, a `JsonSerializationException`, so existing `catch` blocks keep working) with a `"Validation failed: ..."` message; its `Errors` property holds the full `ValidationError`s, located under the converted value's JSON path. The converter clones the `JsonSerializer` (without itself) to avoid recursion. ## API surface | Member | Signature | |---|---| | `DeserializeAndValidate` | `ValidationResult DeserializeAndValidate(this IZodSchema schema, string json, JsonSerializerSettings? settings = null)` | | `DeserializeAndValidate` | `ValidationResult DeserializeAndValidate(this IZodSchema schema, JToken token, JsonSerializer? serializer = null)` | | `DeserializeAndValidateAsync` | `Task> DeserializeAndValidateAsync(this IZodSchema schema, Stream jsonStream, JsonSerializerSettings? settings = null, CancellationToken cancellationToken = default)` | | `ValidateAndSerialize` | `ValidationResult ValidateAndSerialize(this IZodSchema schema, T value, JsonSerializerSettings? settings = null, Formatting formatting = Formatting.None)` | | `ValidateAndSerializeAsync` | `Task> ValidateAndSerializeAsync(this IZodSchema schema, T value, Stream output, JsonSerializerSettings? settings = null, Formatting formatting = Formatting.Indented, CancellationToken cancellationToken = default)` | | `CreateValidatingConverter` | `JsonConverter CreateValidatingConverter(this IZodSchema schema)` | ## Failure codes A failure to read the JSON (malformed JSON, a value that cannot be converted, a `null` document or token) produces one `ValidationError` with code `invalid_json` (`NewtonsoftJsonExtensions.InvalidJsonCode`) and category `invalid_type`. Its `FullPath` is the location the serializer reported (`items[1].age`), its `line` and `position` parameters hold the serializer's one-based line number and line position, and its message is the fixed `"The JSON value could not be converted to {expected}."`, where `expected` is a Zod type name (`string`, `number`, `object`, `array`, …). The serializer's own message, which names CLR types and can quote the input, is never surfaced. A failure raised by a validating converter is returned as the schema's own errors, located under the converter's path. The stream overload validates with `ValidateAsync`, so asynchronous refinements run (it used to call the synchronous `Validate`). A document that fails to read at `{ "Items": [ { "Age": 1 }, { "Age": "x" } ] }` reports `invalid_json` at `Items[1].Age` with `expected` `number`; when the expected type cannot be described, `expected` is the generic `"the expected type"`. A syntax error is located where Json.NET's reader stopped (`Items[1].Age` for `"Age":}` or `"Age":12x`); Json.NET accepts trailing commas, so `"Age":1,}` is not an error. A value that cannot be converted inside a value read by a validating converter keeps its full path (`Items[1].Age`, not `Age`). ## JSON Schema import ```csharp using ZodSharp; using ZodSharp.JsonSchema.NewtonsoftJson; var schema = Z.FromJsonSchema(jsonSchemaString); ``` See [JSON Schema Import](../jsonschema-import/) for the supported keywords, `$ref` handling, and the `JsonSchemaSerializerOptions` defaults. ## System.Text.Json vs Newtonsoft.Json | Aspect | SystemTextJson | NewtonsoftJson | |---|---|---| | Async result type | `ValueTask<...>` | `Task<...>` | | Options parameter | `System.Text.Json.JsonSerializerOptions` | `Newtonsoft.Json.JsonSerializerSettings` | | Converter return | generic `JsonConverter` | non-generic `JsonConverter` | | `JToken` overload | no | yes | | Formatting control | via `JsonSerializerOptions` | explicit `Newtonsoft.Json.Formatting` argument | | Invalid-data exception | `ZodJsonValidationException` (a `JsonException`) | `ZodNewtonsoftJsonValidationException` (a `JsonSerializationException`) | | JSON plumbing | `JsonElement` | `JToken`/`JObject`/`JArray` | :::note `Purview.ZodSharp.SystemTextJson` and `Purview.ZodSharp.NewtonsoftJson` are mutually exclusive integrations — pick the one that matches your JSON library. Both packages can be referenced from the same project without `extern alias` (their JSON Schema types live in the `ZodSharp.JsonSchema.SystemTextJson` / `ZodSharp.JsonSchema.NewtonsoftJson` namespaces), but both packages declare their extensions in the `ZodSharp` namespace with the same names. With both referenced, a call that passes no options or settings (`schema.DeserializeAndValidate(json)`, `DeserializeAndValidateAsync(stream)`, `ValidateAndSerialize(value)`, …) resolves to System.Text.Json, through its option-less overloads; pass a `JsonSerializerSettings` (or a `JToken`) to use Newtonsoft. `CreateValidatingConverter(schema)` has the same signature in both, so it is ambiguous (CS0121): call `NewtonsoftJsonExtensions.CreateValidatingConverter(schema)` or `SystemTextJsonExtensions.CreateValidatingConverter(schema)` directly. ::: --- # ASP.NET Core Integration The `Purview.ZodSharp.AspNetCore` package converts failed validation results into standard `ProblemDetails` / `HttpValidationProblemDetails` payloads while preserving the structured validation issues. It also registers Purview.ZodSharp schema resolution into your application's dependency injection container. ## Install ```bash dotnet add package Purview.ZodSharp.AspNetCore ``` ## ProblemDetails Every path that turns ZodSharp errors into ProblemDetails delegates to one writer, `ZodProblemDetailsWriter`: the endpoint filter, `ZodExceptionHandler`, `ZodTypedResults`, the .NET 11 pipeline enrichment, and every `ToHttpValidationProblemDetails`/`ToValidationProblemDetails`/`ToValidationProblem` overload. Given the same options they all produce the same body (see [The error contract](https://github.com/purview-dev/zodsharp/blob/main/docs/wiki#the-error-contract)); use an overload that takes the request (`HttpContext`) so the options the application registers apply. ```csharp using ZodSharp; app.MapPost("/baskets", (Basket basket, HttpContext httpContext) => { var result = BasketSchema.Validate(basket); return result.IsSuccess ? Results.Ok(result.Value) : result.ToValidationProblem(httpContext); }); ``` The extensions are in the `ZodSharp` namespace and exist for both `ValidationResult` and `ZodException`: | Member | Behaviour | |---|---| | `ToHttpValidationProblemDetails(HttpContext httpContext)` | Uses the writer, and therefore the options, registered in the request services (`ValidationResult` only). **Prefer this in a request.** | | `ToHttpValidationProblemDetails(ZodProblemDetailsOptions options, HttpContext? httpContext = null)` | Uses the supplied options; the form to use outside a request | | `ToValidationProblemDetails(...)` | The same overloads, returning MVC `ValidationProblemDetails` with every extension copied | | `ToValidationProblem(HttpContext httpContext)` | An `IResult` written by the registered writer, keeping the computed status (prefer this) | | `ToHttpValidationProblemDetails(int statusCode = 400)` | *Obsolete.* Built-in defaults, no `ErrorType`s, messages unformatted | | `ToHttpValidationProblemDetails(ErrorTypeRegistry registry, int statusCode = 400)` | *Obsolete.* Resolves `ErrorType`s from `registry` and formats their messages; every other option at its default | | `ToHttpValidationProblemDetails(Func lookup, int statusCode = 400)` | *Obsolete.* Resolves `ErrorType`s through `lookup`; every other option at its default | | `ToValidationProblem()` | *Obsolete.* An `IResult` with built-in defaults (`ValidationResult` only) | The `ValidationResult` overloads throw `InvalidOperationException` when the result `IsSuccess`. ### The option-less overloads The 2.x overloads that take only a status code, an `ErrorTypeRegistry` or a lookup (and `ToValidationProblem()`) take neither options nor a request, so they **cannot see the `ZodProblemDetailsOptions` an application configures** (`AddZodSharpProblemDetails`, `Configure`): `FormatMessages`, `PathNaming`, `IncludeIssues`, `MergeParametersIntoExtensions`, `StatusCodeSelector` and the configured registry are ignored, so their body can differ from the endpoint filter and the exception handler. There is no ambient options instance they could read without a request, so they keep their 2.x output unchanged and are marked `[Obsolete]` (CS0618). Each one is exactly equivalent to the options overload with these options: | Obsolete call | Equivalent | |---|---| | `ToHttpValidationProblemDetails(statusCode)` | `ToHttpValidationProblemDetails(new ZodProblemDetailsOptions { Registry = new(), FormatMessages = false, DefaultStatusCode = statusCode })` | | `ToHttpValidationProblemDetails(registry, statusCode)` | `ToHttpValidationProblemDetails(new ZodProblemDetailsOptions { Registry = registry, DefaultStatusCode = statusCode })` | | `ToHttpValidationProblemDetails(lookup, statusCode)` | `ToHttpValidationProblemDetails(new ZodProblemDetailsOptions { ErrorTypeResolver = lookup, DefaultStatusCode = statusCode })` | | `ToValidationProblem()` | `ToValidationProblem(httpContext)` (applies the registered options) | In a request, move to the `HttpContext` overload; elsewhere (a background job, a test), pass the options you want. ### Typed results `ZodTypedResults` produces the same body for handlers that declare their results: ```csharp using Microsoft.AspNetCore.Http.HttpResults; using ZodSharp.AspNetCore; app.MapPost("/orders", Results, ValidationProblem> (Order order, HttpContext httpContext) => { var result = OrderSchema.Validate(order); return result.IsSuccess ? TypedResults.Ok(result.Value) : ZodTypedResults.ValidationProblem(result, httpContext); }); ``` | Member | Returns | |---|---| | `ZodTypedResults.ValidationProblem(result, httpContext)` / `ValidationProblem(exception, httpContext)` | ASP.NET Core's `ValidationProblem`, for `Results<...>` unions. It is always `400`, even when a mapped `ErrorType` yields another status. | | `ZodTypedResults.Problem(result, httpContext)` / `Problem(exception, httpContext)` | `ZodProblemDetailsResult`, which keeps the computed status (for example a mapped `409`) and writes through `IProblemDetailsService`. | ### `ValidationIssue` The `issues` extension is a `ValidationIssue[]`. `ValidationIssue` is a `readonly record struct` in `ZodSharp.AspNetCore`, and a safe projection of `ValidationError`: | JSON property | CLR member | Value | |---|---|---| | `path` | `DottedPath` | The `errors` key (`Items[0].Quantity`); empty for the root | | `segments` | `Segments` | Zod's path shape: strings for members and keys, integers for indexes | | `code` | `Code` | The ZodSharp code | | `category` | `Category` | The Zod issue code for built-in errors (`too_small`, `invalid_type`, ...) | | `origin` | `Origin` | `string`, `number`, `array`, `date`, ... | | `minimum`/`maximum` | `Lower`/`Upper` | From the error's typed bounds: JSON numbers, or ISO-8601 strings for dates | | `inclusive` | `Inclusive` | Whether the bound is inclusive | | `message` | `Message` | The message, rendered by the registered `IZodMessageFormatter` | | `parameters` | `SafeParameters` | Camel-cased, restricted to invariant scalars and arrays of them | | `unionErrors` | `UnionErrors` | For a failed union, each option's issues, with paths relative to the union | The CLR-only members `Path` (`string[]`), `Minimum`, `Maximum` (`int?`) and `Parameters` (the raw `ErrorTypeParameters`) keep their 2.x meaning for in-process code and are not serialized. ## The error contract A request with a short `CustomerName`, an empty `Lines[0].Sku`, and out-of-range quantities in `Lines[0]` and `ByCode["en"]` produces this body. It is real output from the .NET 11 pipeline with `AddZodSharpProblemDetails()`. The `WithZodSharpValidation()` filter produces the same body. ```json { "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", "title": "One or more validation errors occurred.", "status": 400, "errors": { "CustomerName": ["Field 'CustomerName' must contain at least 3 characters."], "Lines[0].Sku": ["Sku is required"], "Lines[0].Quantity": ["Field 'Quantity' must be greater than or equal to 1 and less than or equal to 100."], "ByCode[en].Quantity": ["Field 'Quantity' must be greater than or equal to 1 and less than or equal to 100."] }, "issues": [ { "path": "CustomerName", "segments": ["CustomerName"], "code": "too_small", "category": "too_small", "origin": "string", "minimum": 3, "inclusive": true, "message": "Field 'CustomerName' must contain at least 3 characters.", "parameters": { "count": 2, "displayName": "CustomerName", "declaringType": "Order", "member": "CustomerName" } }, { "path": "Lines[0].Sku", "segments": ["Lines", 0, "Sku"], "code": "missing_field", "category": "invalid_type", "message": "Sku is required", "parameters": { "displayName": "Sku", "declaringType": "OrderLine", "member": "Sku" } }, { "path": "Lines[0].Quantity", "segments": ["Lines", 0, "Quantity"], "code": "invalid_range", "category": "too_small", "origin": "number", "minimum": 1, "maximum": 100, "inclusive": true, "message": "Field 'Quantity' must be greater than or equal to 1 and less than or equal to 100.", "parameters": { "minimum": "1", "maximum": "100", "displayName": "Quantity", "declaringType": "OrderLine", "member": "Quantity" } }, { "path": "ByCode[en].Quantity", "segments": ["ByCode", "en", "Quantity"], "code": "invalid_range", "category": "too_big", "origin": "number", "minimum": 1, "maximum": 100, "inclusive": true, "message": "Field 'Quantity' must be greater than or equal to 1 and less than or equal to 100.", "parameters": { "minimum": "1", "maximum": "100", "displayName": "Quantity", "declaringType": "OrderLine", "member": "Quantity" } } ], "count": 2, "displayName": "Quantity", "declaringType": "OrderLine", "member": "Quantity", "minimum": "1", "maximum": "100", "traceId": "00-3b87d0e8fa5f0b442bf75ffb97d44b3d-103774b7a225269d-02" } ``` A failed union reports its options' issues under `unionErrors`: ```json { "path": "Contact", "segments": ["Contact"], "code": "invalid_union", "category": "invalid_union", "message": "Value does not match any of the union options (string, number)", "parameters": { "options": ["string", "number"] }, "unionErrors": [ [{ "path": "", "segments": [], "code": "invalid_string", "category": "invalid_format", "origin": "string", "message": "Invalid email format", "parameters": { "format": "email" } }], [{ "path": "", "segments": [], "code": "invalid_type", "category": "invalid_type", "message": "Expected number, received string", "parameters": { "expected": "number", "received": "string" } }] ] } ``` The contract: - **Keys.** `errors` keys and `issues[].path` are `ValidationError.FullPath.ToDottedString()`: members joined with `.`, indexes as `[0]`, and keys as `[en]` or, when ambiguous, quoted (`Map["0"]`, `Map["a.b"]`). The root is `""`. See [Core Concepts](../concepts/core-concepts/#paths). By default member names are the C# names, matching Microsoft.Extensions.Validation and MVC model state. `PathNaming = ZodPathNaming.Json` applies the minimal-API `JsonOptions` naming policy. - **`code` and `category`.** `code` is ZodSharp's specific identity (`invalid_range`). `category` is the Zod issue code (`too_small`), so a client can treat ZodSharp and Zod issues alike. - **Top-level parameters.** Parameters are written per issue only. With `MergeParametersIntoExtensions = true` (the 2.4 behaviour, off by default) every issue's parameters are also merged into the top-level extensions, and the last issue wins. A parameter whose camel-cased name is reserved (`type`, `title`, `status`, `detail`, `instance`, `errors`, `issues`, `traceId`) is written as `param_{name}`. - **No implementation details.** `issues[].parameters` leaves out `declaringType` (a full CLR type name) and `member`, which generated validators attach for localization: they describe the server's types, not the request, and the issue's `path` already names the member. `displayName` is kept. Set `IncludeDiagnosticParameters = true` to send them. - **`traceId`.** It is present on every path: `Activity.Current?.Id`, else `HttpContext.TraceIdentifier`. - **No input values.** Messages and parameters never contain the input unless `ZodErrorOptions.IncludeReceivedValues` is enabled. See [Core Concepts](../concepts/core-concepts/#pii). - **Messages.** They come from the registered `IZodMessageFormatter` in `CultureInfo.CurrentUICulture`. See [Localization](../hosting/localization/). - **OpenAPI.** The body is described by the `ZodValidationProblemDetails` and `ZodValidationIssue` component schemas (see [OpenAPI Integration](../hosting/openapi-integration/)). ## Minimal API validation There are two ways to validate minimal-API parameters: the `WithZodSharpValidation()` endpoint filter, on every target framework, and on .NET 11 the built-in validation pipeline ([below](https://github.com/purview-dev/zodsharp/blob/main/docs/wiki#net-11-the-built-in-validation-pipeline)). ```csharp using ZodSharp.AspNetCore; // Every endpoint of a group: each bound parameter with a registered validator is validated. var api = app.MapGroup("/api").WithZodSharpValidation(options => { options.ThrowIfNoValidator = false; options.OnBindingFailure = ZodBindingFailureBehavior.PassThrough; }); // One endpoint, one type: every bound argument assignable to UserDto is validated. app.MapPost("/users", (UserDto dto) => dto).WithZodSharpValidation(); ``` `WithZodSharpValidation()` works on any `IEndpointConventionBuilder`, including route groups: - **Which parameters.** They are selected when the endpoint is built. Services (`[FromServices]`, keyed services, anything the container resolves), `HttpContext`, `HttpRequest`, `HttpResponse`, `ClaimsPrincipal`, `CancellationToken`, form files, streams and `[SkipValidation]` parameters are skipped. `[FromBody]`, `[FromRoute]`, `[FromQuery]`, `[FromHeader]` and `[FromForm]` force a parameter to be considered. - **How.** Every selected parameter is validated through `IZodSchemaFactory.TryGetUntypedValidator`. - **Error keys.** They follow Microsoft.Extensions.Validation: a complex parameter's errors are not prefixed (`Name`, `Address.Street`), a sequence parameter's are (`items[0].Name`), and an error about the value itself is keyed by the parameter name. - **Response metadata.** The endpoint advertises a `400` `application/problem+json` response (status `ProducesStatusCode`) for OpenAPI. - **The body.** The filter honours `ZodProblemDetailsOptions` and the `ErrorTypeRegistry`, and writes through `IProblemDetailsService`. `WithZodSharpValidation()` (on a `RouteHandlerBuilder`) validates every bound argument assignable to `T`. The validator is resolved when the endpoint is built. When no `IZodSchemaFactory` or no validator for `T` is registered, building the endpoint fails with a message naming the handler and the parameter. | `ZodEndpointValidationOptions` | Default | Effect | |---|---|---| | `ThrowIfNoValidator` | `false` | Fail endpoint building when a complex bound parameter has no registered validator. | | `OnBindingFailure` | `PassThrough` | What to do when binding already failed (see below). | | `ProducesStatusCode` | `400` | The status advertised in response metadata; set it when a mapped `ErrorType` changes the status. | On .NET 11, endpoint filters also run after a route, query, header or form binding failure, with the status already set to 400 and the argument left at its default. A malformed JSON body is answered by ASP.NET Core itself and never reaches a filter. Before .NET 11 this option has no effect. | `ZodBindingFailureBehavior` | Effect | |---|---| | `PassThrough` | Do not validate; ASP.NET Core's binding-failure response stands. | | `ConvertToProblemDetails` | Write the ZodSharp contract instead: an `invalid_type` issue (`invalid_json` for a body) for each unbound validated parameter, or one root `invalid_type` issue when the failed parameter cannot be identified. | ## .NET 11: the built-in validation pipeline On .NET 11, ASP.NET Core validates minimal-API parameters itself through Microsoft.Extensions.Validation. `AddZodSharpValidation()` (package `Purview.ZodSharp.Extensions.Validation`) makes ZodSharp the first validation resolver, and on `net11.0` `AddZodSharpProblemDetails()` turns the pipeline's response into the full ZodSharp contract: ```csharp builder.Services.AddZodSharp(options => options.ScanAssemblies.Add(typeof(Order).Assembly)); builder.Services.AddZodSharpValidation(); builder.Services.AddValidation(); // when DataAnnotations parameters/types or union bodies are used builder.Services.AddZodSharpProblemDetails(); var app = builder.Build(); var orders = app.MapGroup("/orders"); // every endpoint is validated - no per-endpoint call orders.MapPost("/", (Order order, [Range(1, 100)] int page) => TypedResults.Ok()); orders.MapPost("/import", (ImportRequest request) => TypedResults.Ok()).DisableValidation(); ``` How it works: - `AddZodSharpProblemDetails()` registers the bridge's `IZodValidationReportSink`. It appends every full-fidelity ZodSharp error to a `ZodRequestValidationReport` stored on the current request (`HttpContext.Features`), so the report lives and dies with the request. Writes made outside an HTTP request that is still being handled (an interactive Blazor circuit, WebAssembly, a background service) are discarded. - It composes `ProblemDetailsOptions.CustomizeProblemDetails` with a post-configure step. The ZodSharp callback runs **first**, then whatever callback the application configured, whether before or after the call or through its `problemDetails` argument. The application can therefore still change the result. A later `PostConfigure` that replaces the callback outright removes the ZodSharp one. - It calls `AddProblemDetails()`, without which the callback never runs. In the callback, for an `HttpValidationProblemDetails`: | Report | Result | |---|---| | ZodSharp errors reported | `ZodProblemDetailsWriter.Apply` rebuilds `errors` and `issues`. ZodSharp keys are replaced and DataAnnotations entries are kept, each with a `dataannotations` issue. `status`, `title`, `type` and `detail` come from the highest `ErrorType`, then `StatusCodeSelector` and `StatusCodeOverride` are applied. The status is mirrored to the response. | | Empty (DataAnnotations-only failure) | The payload is left untouched, apart from the `issues` projection (`ProjectDataAnnotationsIssues`, default `true`). | The body is byte-identical (apart from `traceId`) to the one the endpoint filter, `ZodExceptionHandler` and `ToValidationProblem(httpContext)` produce. Notes: - **Localization**: register `AddLocalization()` and an `IZodMessageFormatter`, for example `new StringLocalizerZodMessageFormatter(factory, sharedResourceType: typeof(ValidationMessages))`. Messages follow `Accept-Language` (with `UseRequestLocalization`); codes and paths are unchanged. See [Localization](../hosting/localization/). - **The bridge**: resolver placement, `ZodReport` and non-HTTP validation are covered in [Microsoft.Extensions.Validation](../hosting/microsoft-extensions-validation/). - **Unions**: a union parameter is claimed by the runtime parameter resolver, which the application's `AddValidation()` call adds. The active case is reported under `Value` (`Value.Email`). - **Double validation**: `WithZodSharpValidation()` on an endpoint the pipeline validates skips the parameters the bridge covers, logging that once at `Debug` when the endpoint is built. On a `DisableValidation()` endpoint it validates as usual. - **OpenAPI**: the `Purview.ZodSharp.AspNetCore.OpenApi` operation transformer adds the validation problem response for endpoints with a ZodSharp-known parameter. No endpoint metadata is added by the pipeline path. See [OpenAPI Integration](../hosting/openapi-integration/). | | `WithZodSharpValidation()` filter | Built-in pipeline | |---|---|---| | Frameworks | net10.0 and net11.0 | net11.0 | | Scope | endpoints and groups you mark | every endpoint; opt out with `DisableValidation()` | | DataAnnotations parameters | not validated | validated and merged into one body | | Binding failures | `OnBindingFailure` | ASP.NET Core's own response | | Response metadata | adds the 400 response | the OpenAPI transformer | ## Exception handling A thrown `ZodException` (for example from `Parse`, `GetValueOrThrow()`, or a value object's generated `Create` under strict deserialization) can be mapped to ProblemDetails on demand: ```csharp using ZodSharp; try { var parsed = EmailAddressSchema.Parse(rawValue); } catch (ZodException ex) { return ex.ToValidationProblem(httpContext); } ``` Or handled automatically by an `IExceptionHandler`. Register it and ensure `UseExceptionHandler()` is in the pipeline: ```csharp builder.Services.AddZodSharpProblemDetails(); var app = builder.Build(); app.UseExceptionHandler(); ``` `AddZodSharpProblemDetails(Action? configure = null, Action? problemDetails = null)` does the following: - registers `ZodExceptionHandler` and the shared `ZodProblemDetailsWriter`; - calls `services.AddProblemDetails(problemDetails)` and configures `ZodProblemDetailsOptions`; - adds the ProblemDetails payload's source-generated JSON metadata to the minimal-API `JsonOptions`, so it serializes under Native AOT; - on `net11.0`, enriches the built-in validation pipeline ([above](https://github.com/purview-dev/zodsharp/blob/main/docs/wiki#net-11-the-built-in-validation-pipeline)). `AddZodSharp` also registers the writer and the JSON metadata. | `ZodProblemDetailsOptions` | Default | Effect | |---|---|---| | `Registry` | `ErrorTypeRegistry.Default` | Resolves error codes to `ErrorType`s. | | `ErrorTypeResolver` | `null` | A lookup used instead of `Registry`. | | `FormatMessages` | `true` | Format messages from the matched `ErrorType.MessageFormat`, only when every placeholder resolves. | | `PathNaming` | `ZodPathNaming.MemberName` | `Json` renders keys with the minimal-API `JsonOptions` naming policy. | | `IncludeIssues` | `true` | Include the `issues` extension. | | `MergeParametersIntoExtensions` | `false` | Also merge every issue's parameters into the top-level extensions (last wins). | | `IncludeDiagnosticParameters` | `false` | Also write the diagnostic parameters generated validators attach (`declaringType`, the full CLR name of the declaring type, and `member`, the C# member name). They stay on `ValidationError.Parameters` either way, for localization and logging. | | `ProjectDataAnnotationsIssues` | `true` | Give non-ZodSharp `errors` entries an `issues` entry with code `dataannotations` (`ZodProblemDetailsWriter.DataAnnotationsCode`). | | `DefaultStatusCode` | `400` | The status when no matched `ErrorType` supplies one. | | `StatusCodeSelector` | `null` | `Func, int>`: takes complete control of the status. It runs after the highest `ErrorType.HttpStatus` is computed, and its result wins. | | `StatusCodeOverride` | `null` | `Func, int, int>`: like `StatusCodeSelector`, but also receives the computed status. It runs last and wins. | | `HandleBindingFailures` | `false` | Sets `RouteHandlerOptions.ThrowOnBadRequest`, so a body rejected during deserialization reaches `ZodExceptionHandler` in every environment. See [Strict deserialization](https://github.com/purview-dev/zodsharp/blob/main/docs/wiki#strict-deserialization-value-objects-rejected-while-reading). | `MapErrorType(ErrorType)` registers an `ErrorType` with the configured `Registry`. ### Strict deserialization: value objects rejected while reading A value object read by a strict converter (Purview.ValueObjects' `ValueObjectDeserializationMode.Strict`) is validated while the body is deserialized. A converter cannot know which member of the document it is reading, so it wraps the `ZodException` in a `JsonException` (`new JsonException(message, zodException)`), and System.Text.Json records the location in `JsonException.Path` (`$.customerEmail`). ZodSharp joins the two: the errors are reported under the member that held the value, exactly as validating the bound model reports them. ```http POST /orders {"customerEmail":"not-an-email","quantity":5} ``` ```json { "status": 400, "errors": { "CustomerEmail.Value": ["Field 'CustomerEmail' must be a valid email address."] }, "issues": [{ "path": "CustomerEmail.Value", "segments": ["CustomerEmail", "Value"], "code": "invalid_string", "origin": "string", ... }] } ``` - **Member names.** The serializer's path uses JSON names. The endpoint's body type (its `IAcceptsMetadata`) is resolved from the minimal-API `JsonOptions`, and each JSON name is mapped back to its member name through that contract's metadata - no reflection. `PathNaming` then applies as it does everywhere, so the keys match the `WithZodSharpValidation()` body for the same model under either setting. When the body type cannot be resolved, the JSON names are kept. Collections keep their indexes (`$.lines[0].quantity` -> `Lines[0].Quantity.Value`). - **Display name.** An error at the value object's own member (`Value`) is named after the member holding it, as nested validation does: `Field 'CustomerEmail' ...`, never `Field 'Value' ...`. - **A bare `ZodException`** (older value object releases, or any other caller) is unchanged: its own path. - **Any other bad request** (malformed JSON, a `JsonException` without ZodSharp errors) is left as ASP.NET Core answers it, unless `HandleBindingFailures` is set (below). Whether the failure reaches ZodSharp depends on the framework: | | Development | Production | |---|---|---| | **.NET 11** | Rewritten | Rewritten: binding writes its own validation problem through the ProblemDetails service, with the exception attached, and `AddZodSharpProblemDetails` rewrites it. No opt-in needed. | | **.NET 10** | Rewritten (`ThrowOnBadRequest` is on in Development) | A bare `400` with no body, unless `HandleBindingFailures = true`. | ```csharp builder.Services.AddZodSharpProblemDetails(options => options.HandleBindingFailures = true); var app = builder.Build(); app.UseExceptionHandler(); ``` `HandleBindingFailures` sets `RouteHandlerOptions.ThrowOnBadRequest`, which is application-wide: **every** minimal-API binding failure becomes a `BadHttpRequestException` that travels through the exception handler middleware instead of a status code set in place. `ZodExceptionHandler` then answers the ones without ZodSharp errors with their own status (`BadHttpRequestException.StatusCode`) and a standard ProblemDetails body that never includes the exception's message (it can quote the request). The costs are an exception per bad request and the middleware's diagnostics, which follow `ExceptionHandlerOptions.SuppressDiagnosticsCallback`. An unsupported content type (`415`) is always answered by binding itself. MVC (`SystemTextJsonInputFormatter`) is not covered: it records the failure in `ModelState` under the JSON path. Outside ASP.NET Core, `ZodJsonExceptions.TryGetErrors(exception, rootTypeInfo, out errors)` (core package) performs the same re-rooting for any exception chain, and `DeserializeAndValidate*` / `ReadFromJsonAndValidateAsync` apply it, so they return the re-rooted errors rather than `invalid_json`. :::note `AddZodSharpProblemDetails` only wires up exception handling and ProblemDetails services (and, on .NET 11, the pipeline enrichment). It does **not** register `IZodSchemaFactory`. If you also want DI-based validator resolution, register the factory separately with `AddZodSharp` (below) or the core `AddZodSharpFactory`. ::: ## Mapping error types to status codes The `ErrorType` factory lives in the core `Purview.ZodSharp` package: `ErrorType`, the `[ErrorType]` attribute, the source generator that emits `Create`/`Throw` helpers, and the `ZODSASP001`/`ZODSASP002`/`ZODSASP003` analyzers are all shipped with it and are usable without any ASP.NET Core dependency. The `Purview.ZodSharp.AspNetCore` package adds the `ErrorTypeRegistry` and the ProblemDetails mapping on top. Register an `ErrorType` (code, optional category, description, HTTP status, and optional message template) in a registry, then let the mapper derive the status code, title, detail, and formatted messages automatically: ```csharp using ZodSharp.AspNetCore; using ZodSharp.Core; public static partial class ConcurrentErrorType { [ErrorType] public static readonly ErrorType SaveFailed = new( Code: "aggregate_save_failed", Category: "invalid_value", Description: "The aggregate could not be saved.", HttpStatus: 409, MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save", Parameters: [ new("AggregateId", typeof(string)), new("AggregateType", typeof(string)) ]); } // Register once at startup: ErrorTypeRegistry.Default.Register(ConcurrentErrorType.SaveFailed); ``` `HttpStatus` is kept on `ErrorType` purely for convenience; the core library does not use it — only the ASP.NET Core integration reads it when building ProblemDetails responses. The optional `Category` is a broad grouping that can span many specific codes (for example code `invalid_tenant_id` with category `invalid_value`); the generated helpers copy it onto each `ValidationError`, and it is surfaced on the serialized `ValidationIssue` in the `issues` extension. Parameters can also be declared with the `ErrorType.Param("Name")` helper instead of an explicit `typeof(...)`: ```csharp Parameters: [ new("AggregateId", typeof(string)), ErrorType.Param("AggregateType") ] ``` Both the bundled `ZODSASP001` analyzer and the source generator (shipped with the core package) treat `ErrorType.Param` entries exactly like any other declared parameter, so placeholder checking and helper generation are unchanged. When an error carries that code, the response status, title, and message are derived automatically: ```csharp throw new ZodException([ ValidationError.Create( "aggregate_save_failed", "The aggregate could not be saved.", path: [], parameters: new Dictionary { ["AggregateId"] = "agg-123", ["AggregateType"] = "Invoice", }), ]); ``` ### Generated `Create` / `Throw` helpers Marking the field with `[ErrorType]` and the containing class `partial` lets the source generator bundled with the core package turn each field into strongly typed static helpers. For the field above it generates `ConcurrentErrorType.CreateSaveFailed(...)` and `ConcurrentErrorType.ThrowSaveFailed(...)` with one strongly typed parameter per entry in `Parameters` (the declared `typeof(...)` type, or the `ErrorType.Param` generic type argument): ```csharp // Returns a ValidationError with the code, the formatted message, and the typed parameters. var error = ConcurrentErrorType.CreateSaveFailed("agg-123", "Invoice"); // Throws a ZodException carrying the same ValidationError. ConcurrentErrorType.ThrowSaveFailed("agg-123", "Invoice"); // The path and structured issue metadata can be populated too: var error = ConcurrentErrorType.CreateSaveFailed( "agg-123", "Invoice", path: ["order", "items", "[0]"], origin: "array", minimum: 1, maximum: 10, inclusive: true); ``` The generated `Create` builds a typed `ErrorTypeParameters` instance (validated against the declared parameter types and exposed through `ValidationError.Parameters`) and sets the message from `ErrorType.FormatMessage`, so `error.Message` already reads `Aggregate 'agg-123' (of type Invoice) failed to save` and mapping through the registry produces the `409 Conflict` response described below. ### ErrorType diagnostics The `ErrorType` factory, its source generator, and its analyzers ship with the core `Purview.ZodSharp` package: | ID | Severity | Meaning | |---|---|---| | `ZODSASP001` | Warning | A `MessageFormat` placeholder is not declared in `ErrorType.Parameters` | | `ZODSASP002` | Warning | The containing type of an `[ErrorType]` field is not declared `partial` | | `ZODSASP003` | Warning | An `[ErrorType]` field is not declared `static readonly` | | `ZODSASP100` | Error | Unhandled exception in the `ErrorType` source generator | | `ZODSASP101` | Error | The `Parameters` of an `[ErrorType]` field could not be extracted | `ZODSASP001`–`ZODSASP003` explain why `Create`/`Throw` helpers were not generated; `ZODSASP100`/`ZODSASP101` are fatal generator failures that name the field they failed on. Produces a `409 Conflict` `HttpValidationProblemDetails` with: ```json { "type": "https://tools.ietf.org/html/rfc9110#section-15.5.10", "title": "One or more validation errors occurred.", "status": 409, "detail": "The aggregate could not be saved.", "errors": { "": ["Aggregate 'agg-123' (of type Invoice) failed to save"] }, "issues": [ { "path": "", "segments": [], "code": "aggregate_save_failed", "category": "invalid_value", "message": "Aggregate 'agg-123' (of type Invoice) failed to save", "parameters": { "aggregateId": "agg-123", "aggregateType": "Invoice" } } ], "aggregateId": "agg-123", "aggregateType": "Invoice", "traceId": "..." } ``` Mapping rules: - **Status** — the highest matched `ErrorType.HttpStatus` wins; unmapped codes fall back to `DefaultStatusCode` (`400`). `StatusCodeSelector` and then `StatusCodeOverride` run after that computation, and their result wins. - **Title / Type / Detail** — taken from the highest-status matched `ErrorType`; otherwise defaulted. `type` is the RFC 9110 URI for the status. - **Message** — with `FormatMessages`, the `MessageFormat` replaces the error's message only when **every** placeholder resolves, so a template is never returned with `{placeholders}` left in. Placeholders resolve from `ValidationError.Parameters`, then from the built-ins `path`, `code`, `origin`, `minimum`, `maximum` and `displayName`. The analyzer `ZODSASP001` (shipped with the core package) warns at compile time when a `MessageFormat` placeholder is not declared in `Parameters`. - **Parameters** — the error's `ValidationError.Parameters` are surfaced per issue in `issues[].parameters` (camel-cased, safe scalars only). With `MergeParametersIntoExtensions` they are also merged into the top-level extensions, and reserved names are written as `param_{name}`. For on-demand mapping outside a request, pass `ZodProblemDetailsOptions` with a `Registry` or an `ErrorTypeResolver` lookup to `ToHttpValidationProblemDetails` / `ToValidationProblemDetails`; the same mapping applies to `ValidationResult`. The 2.x registry and lookup overloads still work but are obsolete (see [The option-less overloads](https://github.com/purview-dev/zodsharp/blob/main/docs/wiki#the-option-less-overloads)). ## Dependency injection ```csharp builder.Services.AddZodSharp(options => { options.ScanAssemblies.Add(typeof(UserDto).Assembly); options.ScanAssemblyGraphs.Add(typeof(Program).Assembly); options.ScanLoadedAssemblies = true; }); ``` `AddZodSharp(Action? configure)` registers `IZodSchemaFactory` as a singleton. When the factory is built, it does three things in order: 1. It applies every `ZodSchemaRegistration`, including the trim-safe ones added by the generated `services.AddZodSharp{Assembly}Schemas()` extensions. 2. It applies `options.ConfigureFactory`. 3. It calls `factory.RegisterFromAssembly(assembly)` for each distinct assembly discovered from the configured assembly sources, which auto-discovers source-generated `[assembly: ZodSchemaGenerated(typeof(...))]` registrations. Assembly scanning is not trim-safe. In a trimmed or Native AOT host, use the generated `AddZodSharp{Assembly}Schemas()` (see [Source Generator](../generator/source-generator/)). `ZodSchemaFactoryOptions`: - `List ScanAssemblies` — assemblies to scan for generated schemas. - `List ScanAssemblyGraphs` — root assemblies whose referenced assembly graphs should be scanned for generated schemas. - `bool ScanLoadedAssemblies` — whether to scan all currently loaded assemblies for generated schemas. - `Action? ConfigureFactory` — additional factory configuration. For modular registration, the package also exposes additive assembly-contribution helpers: ```csharp builder.Services.AddZodSharp(); builder.Services.AddZodSharpAssembly(typeof(UserDto).Assembly); builder.Services.AddZodSharpAssemblyGraph(typeof(Program).Assembly); builder.Services.AddZodSharpLoadedAssemblies(); ``` - `AddZodSharpAssembly(Assembly)` contributes an exact assembly scan. - `AddZodSharpAssemblyGraph(Assembly)` contributes the root assembly plus its referenced assemblies. - `AddZodSharpLoadedAssemblies()` contributes a scan across assemblies currently loaded into the application domain. :::caution The factory is registered only if one is not already present (`TryAdd` semantics): the **first** `AddZodSharp` (or `AddZodSharpFactory`) call wins, and any later calls — including their `ScanAssemblies`/`ScanAssemblyGraphs`/`ScanLoadedAssemblies`/`ConfigureFactory` settings — are ignored. State is never overwritten, so calling it more than once is safe. The additive assembly-contribution helpers above still contribute before the service provider is built. ::: ### Choosing a registration method | Method | Package | Registers | Use when | |---|---|---|---| | `AddZodSharp(options)` | `Purview.ZodSharp.AspNetCore` | singleton `IZodSchemaFactory` + auto-registers generated validators from configured assembly sources | ASP.NET Core apps that want factory registration plus optional assembly-source configuration. | | `AddZodSharpAssembly(...)`, `AddZodSharpAssemblyGraph(...)`, `AddZodSharpLoadedAssemblies()` | `Purview.ZodSharp.AspNetCore` | additive generated-validator assembly source contributions | Modular ASP.NET Core apps where deeper layers or implementation packages contribute their own schema assemblies. | | `AddZodSharpFactory(configure)` | core `Purview.ZodSharp` | singleton `IZodSchemaFactory` | Any .NET host where you want manual control — register validators yourself in the `configure` callback. | | `AddZodSharp{Assembly}Schemas()` | generated into each assembly with `[ZodSchema]` types | a trim-safe `ZodSchemaRegistration` naming every generated validator | Trimmed or Native AOT hosts, and anywhere assembly scanning is unwanted. | | `AddZodSharpProblemDetails(...)` | `Purview.ZodSharp.AspNetCore` | `ZodExceptionHandler`, the ProblemDetails writer and services, and the .NET 11 pipeline enrichment — does **not** register the factory | Mapping thrown `ZodException`s to `ProblemDetails`; pair it with `AddZodSharp` when you also need DI validator resolution. | | `AddZodSharpValidation(...)` | `Purview.ZodSharp.Extensions.Validation` (`net11.0`) | the Microsoft.Extensions.Validation resolver | Validating with the .NET 11 pipeline; see [Microsoft.Extensions.Validation](../hosting/microsoft-extensions-validation/). | | `AddZodSchemaOptionsValidator(...)` | core `Purview.ZodSharp` | singleton `IValidateOptions` | Validating options objects; requires a factory registered first via `AddZodSharp` or `AddZodSharpFactory`. Validates every options name; on .NET 11 `ValidateOnStart()` runs its asynchronous rules too. | | `OptionsBuilder.BindAndValidateWithZod(section)` | `Purview.ZodSharp.AspNetCore` | binds a configuration section and adds the ZodSharp options validator | Options whose failures should name the configuration key (`Smtp:Servers:0:Host`). Uses the reflection-based binder; under Native AOT call `BindConfiguration` and `AddZodSchemaValidator` yourself. | See [Dependency Injection](../hosting/dependency-injection/) for the underlying factory and options-validation wiring. --- # Microsoft.Extensions.Validation `Purview.ZodSharp.Extensions.Validation` connects ZodSharp to .NET 11's [Microsoft.Extensions.Validation](https://www.nuget.org/packages/Microsoft.Extensions.Validation), the validation pipeline that minimal APIs, Blazor Server and Blazor WebAssembly share. It registers ZodSharp as a validation resolver, so every type that has a ZodSharp validator is validated by ZodSharp wherever the platform validates. That includes nested objects, collections and dictionaries, and it works without `[ValidatableType]` or the Microsoft.Extensions.Validation source generator. - Targets `net11.0` only. - References the `Microsoft.Extensions.Validation` package, never the ASP.NET Core shared framework, so it works in a Blazor WebAssembly client and in worker or console hosts. - `Purview.ZodSharp.AspNetCore` depends on it on `net11.0`. That package turns the pipeline's response into the full ZodSharp ProblemDetails contract (see [ASP.NET Core Integration](../../aspnetcore-integration/)). ## Install ```bash dotnet add package Purview.ZodSharp.Extensions.Validation ``` ## Register ```csharp using ZodSharp.Core; builder.Services.AddZodSharpFactory(factory => factory.Register(new OrderSchemaValidator())); builder.Services.AddZodSharpValidation(); // Keep calling AddValidation() in the application when DataAnnotations types should be validated too: // the Microsoft.Extensions.Validation generator only intercepts calls in your own code. builder.Services.AddValidation(); ``` `AddZodSharpValidation(Action? configure = null)` does the following: - Registers `IZodSchemaFactory` when none is registered yet, as `AddZodSharpFactory()` does. Any registration works: `AddZodSharpFactory`, `AddZodSharp` from the ASP.NET Core package, or the generated `AddZodSharp{Assembly}Schemas()` (see [Source Generator](../../generator/source-generator/)). - Calls `AddValidation()`. - Registers `ZodValidatableInfoResolver` as a singleton. - Places the resolver in `ValidationOptions.Resolvers` from a post-configure step. The generated DataAnnotations resolver inserts itself at index 0 inside its own configure step, so placing ZodSharp afterwards keeps it where `Placement` says. It does not matter whether `AddValidation()` is called before `AddZodSharpValidation()`, after it, or not at all. Calling `AddZodSharpValidation()` more than once registers the resolver once, and every `configure` callback is applied. ### Endpoint validation The endpoint filter Microsoft.Extensions.Validation adds to minimal APIs validates every handler parameter that a registered resolver claims, before the handler runs; it is not switched on by `AddValidation()` itself (called from a library, `AddValidation()` adds no resolver at all). So `AddZodSharpValidation()` **turns on minimal-API validation for every ZodSharp-typed parameter**: an invalid body is answered with `400` and the handler never runs. That is usually the point, but it applies to the whole application: - Opt an endpoint or a route group out with `DisableValidation()`. - Register type validation only with `AddZodSharpValidation(configure, validateParameters: false)`, or set `ZodValidationOptions.ValidateParameters = false`: Blazor forms and `ValidationOptions.ValidateAsync` still use ZodSharp, and no endpoint changes. This is what `AddZodSharpBlazor()` registers. - Any registration that asks for parameters wins (so `AddZodSharpBlazor()` plus `AddZodSharpValidation()`, in either order, validates endpoints), and an explicit `ValidateParameters` overrides both. :::caution Register every validator before the first validation. The resolver caches lookups per type, including misses, so a validator registered with the factory after its type was first looked up is never seen. ::: ## Resolver ordering and DataAnnotations A type is looked up in `ZodValidationOptions.Sources` first, then in `IZodSchemaFactory`. A type found in neither falls through to the next resolver, which is usually the generated DataAnnotations resolver. | `Placement` | Effect | |---|---| | `ZodResolverPlacement.First` (default) | ZodSharp runs ahead of the generated DataAnnotations resolvers, so a type with a ZodSharp validator is always validated by ZodSharp. | | `ZodResolverPlacement.AfterGenerated` | ZodSharp is placed after the generated type resolvers and ahead of the runtime parameter resolvers they append. A type the Microsoft.Extensions.Validation generator knows is validated with DataAnnotations, and ZodSharp handles the rest. | Generated `[ZodSchema]` validators already apply DataAnnotations attributes, so a ZodSharp type is validated by ZodSharp only. Set `AlsoRunDataAnnotations = true` to also run the next resolver that knows the type: ZodSharp runs first, and an identical `(Path, ErrorMessage)` pair is reported once. Nested ZodSharp types are not validated twice. ## Options | `ZodValidationOptions` | Default | Effect | |---|---|---| | `Placement` | `First` | Where the resolver sits; see above. | | `AlsoRunDataAnnotations` | `false` | Also run the next resolver for ZodSharp types, reporting identical messages once. | | `PathNamer` | `null` (C# member names) | Renames member segments in `ValidationError.Path`, for example `ZodPathNamer.FromFunc(JsonNamingPolicy.CamelCase.ConvertName)`. Blazor ignores `Path`, so this never affects field placement. | | `MessageFormatter` | `null` | Formats every `ErrorMessage`. When `null`, a `StringLocalizerZodMessageFormatter` is used whenever the validation's service provider resolves an `IStringLocalizerFactory`, and the ZodSharp message otherwise. See [Localization](../localization/). | | `Sources` | empty | Trim-safe `IZodValidatableInfoSource`s consulted before `IZodSchemaFactory`. | | `ValidateParameters` | `null` (by registration) | Whether the resolver claims parameters, which turns on minimal-API endpoint validation for ZodSharp types; see [Endpoint validation](https://github.com/purview-dev/zodsharp/blob/main/docs/wiki/hosting#endpoint-validation). `null`: claimed after `AddZodSharpValidation()`, not when only `AddZodSharpBlazor()` registered the bridge. | ```csharp using System.Text.Json; using ZodSharp.Core; using ZodSharp.Extensions.Validation; builder.Services.AddZodSharpValidation(options => { options.Placement = ZodResolverPlacement.First; options.PathNamer = ZodPathNamer.FromFunc(JsonNamingPolicy.CamelCase.ConvertName); }); ``` ## What each error carries Each ZodSharp error becomes one `Microsoft.Extensions.Validation.ValidationError`: | `ValidationError` | Value | |---|---| | `Path` | The canonical ZodSharp path prefixed with `ValidateContext.CurrentValidationPath`: `Customer.Address.Street`, `Lines[0].Sku`, `ByCode[abc].Sku`. See [Core Concepts](../../concepts/core-concepts/#paths). | | `Name` | The failing member's name (`Street`); empty for a model-level error. | | `Container` | The object that owns the member (`order.Customer.Address`). Blazor uses it to place the message on the right field. Never a struct: an error inside a struct member (`Email.Value`) is attributed to the nearest reference-type owner and its member (`signUp`, `Email`), because a value type cannot be a `FieldIdentifier` model. | | `ErrorMessage` | The ZodSharp message, formatted by `MessageFormatter` or localized (see [Localization](../localization/)). | `Container` and `Name` come from the generated `TryResolveMember`, so no reflection is needed. Validators that are not generated fall back to a property walker. That walk runs only when validation fails, and it never throws. Parameters follow Microsoft.Extensions.Validation's own conventions: - A complex parameter is not prefixed (`Name`, `Address.Street`). - A collection parameter is prefixed with the parameter name (`items[0].Name`). - An error about the value itself (a missing body, a root refinement) is keyed by the parameter name and has no container. A nullable or optional parameter that is `null` is not validated. `[SkipValidation]` and `[FromServices]` parameters and primitive types are never claimed by ZodSharp. ## Full-fidelity errors A Microsoft.Extensions.Validation error holds only a name, path, message and container. The full ZodSharp errors (code, category, origin, bounds, parameters) travel alongside on one of two side channels: - **`ValidateContext.ZodReport`** returns the `ZodValidationReport` attached to the context, or `null` when ZodSharp reported nothing. The report is held weakly, so it lives exactly as long as the context. - **`IZodValidationReportSink`** is used when the validation's service provider resolves one. The bridge appends every validated value's errors to it as well. Register it with a lifetime equal to one validation run, which is scoped in ASP.NET Core. `AddZodSharpProblemDetails()` registers one (`ZodRequestValidationReport`) on `net11.0`. Do not register a sink in Blazor Server, where the scope is the circuit and errors would accumulate. ```csharp using Microsoft.Extensions.Validation; using ZodSharp.Extensions.Validation; ValidateContext context = new() { ValidationOptions = options, ServiceProvider = services }; await typeInfo.ValidateAsync(order, context, cancellationToken); ZodValidationReport? report = context.ZodReport; ``` Every error in a report has a canonical `FullPath` that already includes `CurrentValidationPath`. It therefore renders to the same key as the matching `Microsoft.Extensions.Validation.ValidationError.Path`. When ZodSharp runs nested under a DataAnnotations type whose sibling members complete asynchronously, the platform validates those members on cloned contexts and merges back only its own errors. The `ZodReport` can then be on a clone. Register a sink where that matters. When ZodSharp validates the root value (minimal API parameters, Blazor forms of ZodSharp models), the report is always on the caller's context. ## Validating outside HTTP and Blazor `ValidationOptions.ValidateAsync(value, services, cancellationToken)` validates through the configured resolvers in worker services, console hosts and tests, and returns a `ZodValidationReport`: ```csharp using Microsoft.Extensions.Options; using Microsoft.Extensions.Validation; var options = serviceProvider.GetRequiredService>().Value; var report = await options.ValidateAsync(order, serviceProvider, cancellationToken); foreach (var error in report.Errors) Console.WriteLine($"{error.FullPath.ToDottedString()}: {error.Message} ({error.Code})"); ``` The runtime type of `value` is used when it differs from `T`. ZodSharp errors keep full fidelity. Errors from other resolvers (DataAnnotations) are converted with code and category `custom`, and their path is parsed from the Microsoft.Extensions.Validation key with `ZodPath.ParseDotted`. :::note `ZodSharp.Core.ValidationError` and `Microsoft.Extensions.Validation.ValidationError` have the same simple name. In a file that imports both namespaces, qualify one or add an alias such as `using ZodValidationError = ZodSharp.Core.ValidationError;`. ::: ## Synchronous and asynchronous validation The bridge behaves like the platform's own type info: - **Sync with async rules throws.** Synchronous validation of a type with asynchronous rules throws `InvalidOperationException`, as `EditContext.Validate()` does. Use the async APIs: minimal APIs already do, and in Blazor use `EditContext.ValidateAsync()`. A type has asynchronous rules when its `RequiresAsyncValidation` is `true`. - **Async rules can use services.** Asynchronous validation passes `ValidateContext.ServiceProvider` through to service-aware rules (see [Source Generator](../../generator/source-generator/)). - **Field-level validation does not recurse.** It runs only the changed member's own rules (`ValidateMember`). Nested schemas, type-level rules and refinements are not run, because Blazor attributes every error of a field run to the changed field. Members without rules are not offered for field-level validation (`IsValidatableMember`). ## Depth limit The adapter throws `InvalidOperationException` when `ValidateContext.CurrentDepth` reaches `ValidationOptions.MaxDepth`, like Microsoft.Extensions.Validation. Generated validators also recurse internally with their own guard, `ZodValidationDepth.MaxDepth` (default `32`). The bridge sets that value from `ValidationOptions.MaxDepth`, so a cyclic graph fails at the same depth however it is validated. ## Unions A C# union that has no validator of its own is validated through its active case, the way Microsoft.Extensions.Validation does. The case is reported under `Value` (`Value.Name`, `Value[0].Name`) and validated by whichever resolver knows the case type. For a union minimal-API parameter, the runtime parameter resolver that the application's own `AddValidation()` call adds claims the parameter. See [Unions and Discriminated Unions](../../schemas/unions-and-discriminated-unions/). ## Trim-safe sources `ZodValidationOptions.Sources` supplies validators by type without the factory: ```csharp using ZodSharp.Core; using ZodSharp.Extensions.Validation; sealed class OrderValidators : IZodValidatableInfoSource { public bool TryGetValidator(Type type, out IZodUntypedValidator? validator) { validator = type == typeof(Order) ? new OrderSchemaValidator() : null; return validator is not null; } } builder.Services.AddZodSharpValidation(options => options.Sources.Add(new OrderValidators())); ``` Generated `{Type}SchemaValidator`s implement `IZodUntypedValidator`. For a hand-written `IZodSchemaValidator`, wrap it in `new ZodUntypedValidator(validator)`. The generated per-assembly registration (`AddZodSharp{Assembly}Schemas()`) is also trim-safe. Assembly scanning is not. ## Limitations - **Runtime dictionary schemas have no owning object.** A runtime `Z.Object()` schema validating a dictionary or JSON value has no CLR object a field can be bound to. Its errors carry `Container = null` and the full path as `Name`, so Blazor shows them in the validation summary rather than next to a field. - **Models declared in `.razor` files are not generated.** The Razor compiler generates C# from `@code` blocks after source generators run, so the ZodSharp generator never sees a `[ZodSchema]` type declared there. The generator reports `ZODSGEN051`; move the type to a `.cs` file (see [Source Generator Diagnostics](../../generator/source-generator-diagnostics/)). - **Lookups are cached.** Register validators before the first validation (see above). ## See also - [ASP.NET Core Integration](../../aspnetcore-integration/): the minimal-API pipeline and the ProblemDetails contract. - [Blazor Integration](../blazor-integration/): forms, field placement and ``. - [OpenAPI Integration](../openapi-integration/) - [Localization](../localization/) - [Core Concepts](../../concepts/core-concepts/#error-fields): the error fields and the canonical path format. --- # OpenAPI Integration `Purview.ZodSharp.AspNetCore.OpenApi` adds ASP.NET Core 11 OpenAPI transformers so the generated document describes exactly what ZodSharp enforces: constraints, formats, unions, maps, required members and the validation error contract. - `net11.0` only. ASP.NET Core 11 builds documents on the Microsoft.OpenApi 3.x object model. - Kept separate from `Purview.ZodSharp.AspNetCore`, so the core ASP.NET Core package never forces a Microsoft.OpenApi dependency. ## Install ```bash dotnet add package Purview.ZodSharp.AspNetCore.OpenApi ``` ## Register ```csharp using Microsoft.AspNetCore.OpenApi; builder.Services.AddZodSharp(options => options.ScanAssemblies.Add(typeof(Program).Assembly)); builder.Services.AddOpenApi(options => options.AddZodSharp()); var app = builder.Build(); app.MapOpenApi(); ``` `AddZodSharp` on `OpenApiOptions` is an extension in the `Microsoft.AspNetCore.OpenApi` namespace. Web projects do not import that namespace implicitly, so add the `using`. It registers three transformers: | Transformer | Role | |---|---| | `ZodOpenApiSchemaTransformer` | Applies ZodSharp's constraints, required members, nullability and union shape to each schema. | | `ZodOpenApiOperationTransformer` | Adds the validation problem responses to operations that bind a ZodSharp-validated parameter. | | `ZodOpenApiDocumentTransformer` | Adds the shared `ZodValidationProblemDetails` and `ZodValidationIssue` component schemas, and rewrites marked unions from `anyOf` to `oneOf`. | ## Options ```csharp using Microsoft.AspNetCore.OpenApi; builder.Services.AddOpenApi(options => options.AddZodSharp(zod => { zod.EmitVendorExtensions = true; zod.DiscriminatedUnionsAsOneOf = true; zod.DescribeValidationProblems = true; })); ``` | `ZodOpenApiOptions` | Default | Effect | |---|---|---| | `EmitVendorExtensions` | `true` | Describes rules that have no JSON Schema keyword, or that a keyword only approximates, with `x-zod-*` vendor extensions, and lists rules that cannot be represented under `x-zodsharp-unsupported`. | | `DiscriminatedUnionsAsOneOf` | `true` | Describes discriminated unions and polymorphic types with `oneOf` plus a `discriminator` instead of `anyOf`. | | `DescribeValidationProblems` | `true` | Lists the validation problem responses on operations whose parameters ZodSharp validates. | The component ids and the media type are constants: `ZodOpenApiOptions.ValidationProblemDetailsSchemaId` (`ZodValidationProblemDetails`), `ZodOpenApiOptions.ValidationIssueSchemaId` (`ZodValidationIssue`) and `ZodOpenApiOptions.ProblemJsonContentType` (`application/problem+json`). ## Source of truth For each type, the transformer uses ZodSharp's own JSON Schema (see [JSON Schema Export](../../serialization/jsonschema-export/)): - the generated `BuildJsonSchema()` of a `[ZodSchema]` type (no reflection), or - a runtime schema registered in `IZodSchemaFactory`, converted with `ToJsonSchemaConverter`. A type ZodSharp does not know is left exactly as ASP.NET Core produced it. When a DataAnnotation that the platform mapped disagrees with ZodSharp, ZodSharp's value is written, because ZodSharp is the validator that enforces the request. In Development each override is logged at `Debug`. Identical values are left alone. ## Keyword mapping The platform maps a few DataAnnotations by itself (`[Range]`, `[RegularExpression]`, `[MinLength]`, `[MaxLength]`, `[Length]`, `[StringLength]`, `[Url]`, `[Base64String]`, `[Required]`, `[Description]`). It maps no ZodSharp rule attribute and not `[EmailAddress]`. With the transformers, the document says: | ZodSharp rule or attribute | OpenAPI keyword | |---|---| | `MinLength`/`MaxLength`/`Length`/`StringLength` on a string | `minLength`/`maxLength` | | the same on a collection | `minItems`/`maxItems` | | the same on a map | `minProperties`/`maxProperties` | | `MinValue`/`MaxValue`/`GreaterThanOrEqual`/`LessThanOrEqual` | `minimum`/`maximum` | | `GreaterThan`/`LessThan` | `exclusiveMinimum`/`exclusiveMaximum` (numeric, the 3.1+ form) | | `MultipleOf` | `multipleOf` | | `Int` | `type: integer` | | `SafeInteger` | `type: integer`, with `minimum`/`maximum` set to ZodSharp's safe range (`int.MinValue`..`int.MaxValue`) | | `Regex`/`RegularExpression` | `pattern`; several patterns become `allOf: [{ pattern }, ...]` | | `StartsWith`/`EndsWith`/`Includes` | an escaped `pattern` (`^ab`, `yz$`, `mid`), plus `x-zod-startsWith`/`x-zod-endsWith`/`x-zod-includes` | | `Email`/`EmailAddress` | `format: email` | | `Url` | `format: uri` | | `UUID(version)` | `format: uuid` and `x-zod-uuidVersion` | | `IPAddress` (IPv4/IPv6) | `format: ipv4`/`format: ipv6` (an any-version rule records `x-zod-ipVersion`) | | `Cidr` | `x-zod-cidr` (`v4`, `v6` or `any`), and listed under `x-zodsharp-unsupported` | | `JWT` | `x-zod-jwt: true`, and listed under `x-zodsharp-unsupported` | | `DatetimeString` | `format: date-time` | | `DateString` | `format: date` | | `TimeString` | a local-time `pattern` and `x-zod-format: time` | | `Duration` | `format: duration` | | `Base64String` | `contentEncoding: base64` | | `AllowedValues`, enum members | `enum` | | `DeniedValues` | `not: { enum: [...] }` | | `[Display(Description)]` | `description` (the platform does not map it) | | `[Required]`, `[RequiredZod]`, non-nullable reference members | listed in the type's `required` | A `TimeSpan` member keeps the platform's description. System.Text.Json writes a `TimeSpan` in its constant (`c`) format, which is not an ISO 8601 duration, so `format: duration` is not applied. An enum member's schema becomes the enum's shared component, so only the enum's own value set is written there. The values are written as System.Text.Json writes them with the application's options: numbers by default, and member names (after the converter's naming policy and any `[JsonStringEnumMemberName]`) when a `JsonStringEnumConverter` applies globally (`ConfigureHttpJsonOptions`) or to the enum type. Nested types, collection elements and map values are constrained through their own component schemas. Constraints on `[AsParameters]`, query and route parameters of ZodSharp-known types come from the containing type's schema. ## Nullability and required members Nullability uses the JSON Schema form `type: [x, "null"]`, never the OpenAPI 3.0 `nullable` flag. Members ZodSharp rejects when absent are added to `required`: non-nullable references, `[Required]` and `[RequiredZod]`. A `[RequiredZod]` member typed as nullable is described as non-nullable, because ZodSharp rejects `null`. ## Unions and polymorphism | Shape | Description | |---|---| | A C# union | The platform's `anyOf` over the case schemas is kept. ZodSharp only constrains each case it knows and never restructures the union. | | A polymorphic type (`[JsonPolymorphic]`/`[JsonDerivedType]`, or a closed hierarchy) | `oneOf` with the platform's `discriminator`, because exactly one case matches. | | A runtime `ZodDiscriminatedUnion` | `oneOf` with a `discriminator` whose `propertyName` is the union's discriminator. | With `DiscriminatedUnionsAsOneOf = false`, all three keep `anyOf`. See [Unions and Discriminated Unions](../../schemas/unions-and-discriminated-unions/). ## Validation problem responses Every operation that binds a ZodSharp-validated parameter lists `400` as `application/problem+json`, referencing `#/components/schemas/ZodValidationProblemDetails`. A ZodSharp-validated parameter is a body, an `[AsParameters]` container, or a C# union whose cases ZodSharp knows. It also lists `422` when an `ErrorType` registered in the `ErrorTypeRegistry` uses that status. - Both minimal-API modes are covered: the built-in validation pipeline (`AddZodSharpValidation()`) and the `WithZodSharpValidation()` filter. - Services and special parameters (`HttpContext`, `CancellationToken`, and so on) are skipped. - A response the endpoint already declares, typically with `ProducesValidationProblem()`, is merged into rather than duplicated. Its description is kept, and its `application/problem+json` schema is replaced with the ZodSharp contract. The two component schemas describe the body that [ASP.NET Core Integration](../../aspnetcore-integration/#the-error-contract) documents: - `ZodValidationProblemDetails` is RFC 9457 `HttpValidationProblemDetails` plus `issues` (an array of `ZodValidationIssue`) and `traceId`. - `ZodValidationIssue` has these properties: - `path`, `code` and `message`, which are required. `code` is the ZodSharp error code (`invalid_email`, `missing_field`, `too_small`, …). - `segments`, an array of `string | integer`. - `category`, the Zod (v4) issue code the error maps to (`too_small`, `invalid_format`, …), then `origin` and `inclusive`. - `minimum`/`maximum`, each a number or a `date-time` string. - `parameters` and `unionErrors`, an array of arrays of `ZodValidationIssue`. ## OpenAPI 3.1 and 3.2 ASP.NET Core 11 writes OpenAPI 3.2 by default. The output is the same for 3.1 and 3.2, and both are covered by the test suite. To write 3.1: ```csharp using Microsoft.AspNetCore.OpenApi; using Microsoft.OpenApi; builder.Services.AddOpenApi(options => { options.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1; options.AddZodSharp(); }); ``` The transformers always write the 3.1+ JSON Schema forms: numeric `exclusiveMinimum`/`exclusiveMaximum`, `type` arrays for nullability, and `contentEncoding`. OpenAPI 3.0 is not a supported target. ## Vendor extensions With `EmitVendorExtensions` on, rules that no standard keyword captures are recorded as `x-zod-*` extensions: | Extension | Rule | |---|---| | `x-zod-startsWith`, `x-zod-endsWith`, `x-zod-includes` | `StartsWith`, `EndsWith`, `Includes` (the literal; the `pattern` is the escaped form) | | `x-zod-uuidVersion` | `UUID(version)`, as an integer | | `x-zod-ipVersion` | an any-version `IPAddress` rule (`any`) | | `x-zod-cidr` | `Cidr` (`v4`, `v6` or `any`) | | `x-zod-jwt` | `JWT` | | `x-zod-format` | `TimeString` (`time`) | | `x-zodsharp-unsupported` | the names of rules that have no JSON Schema representation (`CidrRule`, `JWTRule`, ...) | The same extensions are available outside OpenAPI from `JsonSchemaDefinition.GetVendorExtensions()`. They never change the serialized JSON Schema (see [JSON Schema Export](../../serialization/jsonschema-export/)). ## See also - [ASP.NET Core Integration](../../aspnetcore-integration/) - [Microsoft.Extensions.Validation](../microsoft-extensions-validation/) - [JSON Schema Export](../../serialization/jsonschema-export/) --- # Blazor Integration `Purview.ZodSharp.Blazor` validates Blazor `EditForm` models with ZodSharp on **Blazor Server**, **Blazor WebAssembly** and **Auto** render mode. It targets `net11.0` and references the component packages (`Microsoft.AspNetCore.Components.Forms` and `.Web`), never the ASP.NET Core shared framework, so the same package runs in the browser. ```bash dotnet add package Purview.ZodSharp.Blazor dotnet add package Purview.ZodSharp.AspNetCore # server project only, for static-SSR client rules ``` ## Setup ```csharp // Program.cs - the server AND the client project. builder.Services.AddZodSharpMyAppSharedSchemas(); // generated, trim-safe registration of [ZodSchema] models builder.Services.AddZodSharpBlazor(); ``` A Blazor Web App typically looks like this: ``` MyApp/ (server) -> Purview.ZodSharp.Blazor, Purview.ZodSharp.AspNetCore MyApp.Client/ (WASM) -> Purview.ZodSharp.Blazor MyApp.Shared/ (models) -> Purview.ZodSharp (the generator runs here) ``` `AddZodSharpBlazor(configureValidation, configure)` registers the Microsoft.Extensions.Validation bridge for **type validation only** (`AddZodSharpValidation(configureValidation, validateParameters: false)`), registers `ZodBlazorOptions` and registers `TimeProvider.System` when no `TimeProvider` is registered. It never registers an `IZodValidationReportSink`. :::caution **`AddZodSharpBlazor` does not turn on minimal-API validation.** Forms and minimal APIs share one `ValidationOptions`, and its endpoint filter validates every handler parameter a registered resolver claims, before the handler runs. So the bridge `AddZodSharpBlazor` registers claims no parameters: a Blazor Server host that also maps endpoints keeps them exactly as they were. To have ZodSharp-typed endpoint parameters validated by the .NET 11 pipeline as well (an invalid request answered with `400` before the handler), opt in: ```csharp builder.Services.AddZodSharpBlazor(); builder.Services.AddZodSharpValidation(); // either order ``` or `AddZodSharpBlazor(validation => validation.ValidateParameters = true)`. Once it is on, opt single endpoints or groups out with `DisableValidation()`, or every parameter with `ValidateParameters = false`. A WebAssembly client maps no endpoints, so this only matters on the server. ::: ## Two routes | Need | Route | | --- | --- | | Standard forms, `[ZodSchema]` models, minimal setup | 1: `` | | Codes, categories, origins or bounds in the UI; custom message rendering | 2: `` | | A runtime `Z.Object(...)` schema for a CLR model | 2: `Schema` parameter | | API (server) validation errors on fields | either, plus `AddZodProblemDetails` | | Debounced per-keystroke validation | 2: `Input` + `DebounceMilliseconds` | | Field-level re-check of cross-field rules | 2: `ValidateNestedOnFieldChange` | ### Route 1: `` ```razor @foreach (var line in order.Lines) { } ``` - **No `[ValidatableType]` and no Microsoft.Extensions.Validation generator** are needed for `[ZodSchema]` models. The ZodSharp resolver supplies the root type info, so Blazor takes its full-validation path: nested objects, collections (`Lines[0].Quantity`), dictionaries and unions each place their message on the right `FieldIdentifier`. In WebAssembly this is the difference between nested validation and top-level-only validation, because the Microsoft.Extensions.Validation generator does not flow to a client project. - A field change runs only that member's own rules (`ValidateMember`), never nested schemas. - Async rules use the platform's async validation, so `IsValidationPending`, `IsValidationFaulted` and the `pending`/`faulted` CSS classes work. - Messages are localized through the shared formatter when `AddLocalization()` is configured. The platform's message store holds strings only, so Route 1 cannot carry codes or other metadata. ### Route 2: `` ```razor ``` `Input` debounces changes, so the input has to report one per keystroke. `InputText` commits on `onchange`, and a component binding has no event to choose (`@bind-Value:event="oninput"` on `InputText` fails at runtime), so derive from it: ```razor @* LiveInputText.razor *@ @inherits InputText ``` | Parameter | Default | Meaning | | --- | --- | --- | | `Schema` | `null` | An explicit `IZodUntypedValidator` for the model (see `ZodModelSchema`); otherwise the registered validator. | | `ValidateOn` | `Change` | `Submit` (field changes don't validate), `Change`, `Input`. Submit-time validation always runs. | | `DebounceMilliseconds` | `0` | Delay for `Input`-triggered field validation; each change supersedes and cancels the pending one. | | `EnableClientValidation` | `true` | Emit static-SSR client rules, exactly as `DataAnnotationsValidator` does. | | `ValidateNestedOnFieldChange` | `false` | Re-validate the whole object that owns the changed field and update all its fields. | Behaviour: - Form validation uses `ValidationRequestedEventArgs.AddAsyncValidator`; field validation uses `EditContext.RegisterAsyncFieldValidator`. Pending, faulted, cancellation and supersession come from the platform. Store updates are marshalled through `InvokeAsync`. - A field change on a member the validator can validate on its own runs only that member's rules. For runtime schemas without member validation the whole model is validated and only the changed field's errors are kept; other fields' messages are left exactly as they were. - The full `ValidationError` of every message is kept in `EditContext.Properties[ZodValidator.ErrorsKey]` and read with `GetZodErrors(field)`, `GetZodErrors(() => model.Member)` or `GetZodErrors()`. - A validator that throws is logged through `ILogger` with the field, and the field (or form) is marked faulted. - With a `DataAnnotationsValidator` on the same form, `ZodValidator` records the full errors for the types the bridge validates but leaves the messages to `DataAnnotationsValidator`, so nothing is duplicated, and logs one warning (`ZodBlazorOptions.WarnOnValidatorCoexistence`). A `DataAnnotationsValidator` with client validation disabled is not detected. ### Runtime schemas ```csharp var schema = Z.Object() .Field("Reference", Z.String().Min(3)) .Field("Customer", Z.Object().Field("Email", Z.String().Email()).Build()) .Build(); IZodUntypedValidator validator = ZodModelSchema.For(schema); ``` `ZodModelSchema.For(ZodObject)` reads the model's public properties (and nested objects) into the key/value shape the object schema validates and places the errors on the CLR fields. Collections are passed as they are. `ZodModelSchema.For(IZodSchema)` adapts a schema of the model's own type. ### Value objects and other struct members A struct cannot be a `FieldIdentifier` model (Blazor rejects a value-type model, and a boxed copy is not the instance the form binds), so an error located inside a struct member is placed on the nearest reference-type owner and its member. Given a scalar value object on the form model: ```csharp [Scalar] [ZodSchema] public readonly partial record struct EmailAddress { [Required] [EmailAddress] public string Value { get; } } [ZodSchema] public sealed class Registration { public EmailAddress Email { get; set; } } ``` the error at `Email.Value` is shown by `` (or `ZodValidationMessage`), next to the value object's input. Both routes, both hosts (Server and WebAssembly) and `AddZodProblemDetails` place it the same way; `GetZodErrors(() => model.Email)` returns it, with its full path `Email.Value` unchanged. A struct inside a collection (`Stops[1].Latitude`) is placed on the collection member (`Stops`). A scalar value object's own member is an implementation detail, so its messages use the **owning member's display name**: `"Email is required"`, `"Field 'Contact email' must be a valid email address."` with `[Display(Name = "Contact email")]`, rather than `"Value is required"`. The `displayName` parameter carries the owner's name too; `member` and `declaringType` still identify the value object's member, for localization. A rule's own message text (one not produced from its template) is kept. A struct that is not a scalar keeps its members' names (`"Field 'Latitude' must be greater than or equal to -90 and less than or equal to 90."`), since it may have several. ## Message components `` and `` render the same markup and CSS classes as `ValidationMessage` and `ValidationSummary` (interactive and static, including the static-SSR `data-valmsg-for` placeholders), plus `data-code` and `data-category` when the full error is known. - `ZodValidationMessage`: `ShowCode` (a `span.validation-code`), `Template` (`RenderFragment`). - `ZodValidationSummary`: `Model`/`ShowModelOnly` (model-level messages only), `GroupByPath` (`li.validation-group[data-path]`), `ShowUnionDetails` (each union option's issues). With Route 1 both fall back to plain messages. ## Server errors onto forms ```csharp var response = await http.PostAsJsonAsync("/orders", order); if (await response.TryReadZodValidationProblemAsync() is { } problem) editContext.AddZodProblemDetails(problem); ``` - `TryReadZodValidationProblemAsync` reads an unsuccessful JSON response with `errors` or `issues` - the contract [ASP.NET Core Integration](../../aspnetcore-integration/) writes - using source-generated JSON metadata. It and `ZodValidationProblemDetails` (namespace `ZodSharp.Http`) come from `Purview.ZodSharp.Http`, which this package references; see [HttpClient Integration](../http-integration/) for validating request and response bodies too. - `AddZodProblemDetails` places issues by their typed `segments`, so `Lines[0].Quantity` lands on `order.Lines[0].Quantity`; without `issues` it parses the dotted `errors` keys. Camel-cased member names match case-insensitively, and dictionary keys resolve. When a `ZodValidator` is on the form its validator resolves the paths. - Server errors replace the previous server errors and are cleared per field when the field changes (`clearOnFieldChange: false` keeps them). `ClearZodErrors()` removes every ZodSharp error. - `AddZodErrors(errors)` places errors produced outside the form the same way - for example those `PostAsJsonValidatedAsync` returns when it refuses to send an invalid value: ```csharp var sent = await http.PostAsJsonValidatedAsync("/orders", order, new OrderSchemaValidator(), AppJsonContext.Default.Order, services); if (!sent.IsSuccess) editContext.AddZodErrors(sent.Errors); else if (await sent.Value!.TryReadZodValidationProblemAsync() is { } problem) editContext.AddZodProblemDetails(problem); ``` It shares `AddZodProblemDetails`'s store and rules: each call replaces the previous one's errors, they clear per field on change, and `GetZodErrors` returns the full errors. ## Static SSR client-side validation ```csharp // Server Program.cs builder.Services.AddRazorComponents(); builder.Services.AddZodSharpClientValidation(); // Purview.ZodSharp.AspNetCore; either order works ``` A statically rendered form with `` (or ``) then carries, beside ASP.NET Core's DataAnnotations rules, client rules for: | Attribute | Client validator | | --- | --- | | `[StartsWith]`, `[EndsWith]` (ordinal or ordinal-ignore-case) | `zod-startswith`, `zod-endswith` | | `[Includes]` | `zod-includes` | | `[UUID]` (optionally versioned) | `zod-uuid` | | `[Cuid2]`, `[ULID]` | `zod-cuid2`, `zod-ulid` | | `[IPAddress]`, `[Cidr]` | `zod-ip`, `zod-cidr` | | `[Regex]` | `zod-regex` (a match anywhere, like `Regex.IsMatch`) | | `[Int]`, `[MultipleOf]` | `zod-int`, `zod-multipleof` | | `[MinValue]`, `[MaxValue]`, `[GreaterThanOrEqual]`, `[LessThanOrEqual]` | `zod-min`, `zod-max` | | `[GreaterThan]`, `[LessThan]` | `zod-gt`, `zod-lt` | Custom rule attributes get the same client rules when their rule reports the same `IZodRule` metadata (format parameters and bounds); a custom attribute can also implement `IClientValidationRuleProvider` with `ZodClientValidationRules.For(rule)`. Runtime schemas have no attributes: register them with `AddZodSharpClientValidation(o => o.AddSchema(schema))` and the rules are derived from the schema's JSON Schema (`minLength`, `maxLength`, `pattern`, bounds, `multipleOf`, `integer`, `uuid`/`ipv4`/`ipv6`). The browser validators live in `_content/Purview.ZodSharp.Blazor/zodsharp-validation.js` and register through a Blazor JavaScript initializer, so no `