# 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. - Repository: https://github.com/purview-dev/zodsharp - Package: https://www.nuget.org/packages/Purview.ZodSharp - Project page: https://purview.dev/projects/zodsharp/ - Documentation: https://purview.dev/docs/zodsharp/ - Full machine-readable content: https://purview.dev/projects/zodsharp/llms-full.txt # Getting Started ## Install ```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($" - {string.Join(".", error.Path)}: {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](source-generator/) and [Source Generator DataAnnotations](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](systemtextjson-integration/), [Newtonsoft.Json Integration](newtonsoftjson-integration/), [JSON Schema Export](jsonschema-export/), and [JSON Schema Import](jsonschema-import/). ## Next pages - [Core Concepts](core-concepts/) - [Fluent Schema API](fluent-schema-api/) - [Source Generator](source-generator/) - [Dependency Injection](dependency-injection/) - [Cross-Platform Interop](cross-platform-interop/) - [Performance](performance/) --- # 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` with code `"validation_failed"`. ```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. | ## 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: - `Code` — e.g. `"invalid_type"`, `"too_small"`, `"too_big"`, `"invalid_union"`, `"missing_field"`, `"unrecognized_key"`, `"validation_failed"`, `"refinement_failed"`, `"transform_error"`. - `Message` — human-readable message. - `Path` — `ImmutableArray`, e.g. `["user", "email"]`; array indexes appear as string segments such as `"0"`. - `Origin` — category for structured size issues (`"string"`, `"array"`, `"collection"`). - `Minimum` / `Maximum` — inclusive bounds for size issues. - `Inclusive` — whether the bound is inclusive. - `Parameters` — `IReadOnlyDictionary`, e.g. union failures carry every option error under `"errors"`. Create custom issues with `ValidationError.Create(code, message, path, parameters, origin, minimum, maximum, inclusive)`. ## ZodException `ZodException` (namespace `ZodSharp.Core`) is thrown by `Parse` and `GetValueOrThrow()`. It exposes `ImmutableArray Errors`. Its `ToString()` renders one line per error: `"{joinedPath}: {Message} ({Code})"`. ```csharp try { var value = schema.Parse("AB"); } catch (ZodException ex) { foreach (var error in ex.Errors) Console.WriteLine($"{string.Join(".", error.Path)}: {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](../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](../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` | | `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](../string-validation/) — `ZodString`. - [Number Validation](../number-validation/) — `ZodNumber`. - [Object Validation](../object-validation/) — `ZodObject`, `ZodObjectBuilder`. - [Arrays and Other Schemas](../arrays-and-other-schemas/) — `ZodArray`, `ZodBoolean`, `ZodNull`, `ZodEnum`, `ZodNativeEnum`, `ZodLiteral`, `ZodRecord`, `ZodTuple`, `ZodLazy`. - [Unions and Discriminated Unions](../unions-and-discriminated-unions/) — `ZodUnion`, `ZodTypedUnion`, `ZodDiscriminatedUnion`. - [Composition and Transforms](../composition-and-transforms/) — `ZodTransform`, `ZodRefinement`, `ZodSuperRefinement`, `ZodPipe`, `ZodCatch`, `ZodDefault`, `ZodPrefault`, `ZodIntersection`. - [Compiled Validators and Caching](../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](../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. `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`. See [Custom Rules](../custom-rules/) for defining, attaching, and mapping rules (including generic rules). ## 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](../composition-and-transforms/). --- # String Validation `ZodString` (namespace `ZodSharp.Schemas`) validates `string` values. `ParseInternal` rejects `null` with an `invalid_type` error (`"Expected string, but got 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` via `validation_failed` 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 | | `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, string? message)` | `StartsWithRule` — ordinal comparison | | `EndsWith` | `EndsWith(string suffix, string? message)` | `EndsWithRule` — ordinal comparison | | `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`. ::: ## 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 code `validation_failed` and an empty path. Many methods accept a custom `message` parameter. Rule structs live in `ZodSharp.Rules` and can be reused standalone with `IValidationRule`. ## 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, but got 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` | | `Int` | `Int()` | `IntRule` — `value == Math.Truncate(value)` | | `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` — `double.IsFinite` | | `Safe` | `Safe(string? message)` | `SafeIntegerRule` — integer within `int.MinValue`..`int.MaxValue` | ## 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 age = Z.Number().Min(0).Max(120).Int().Validate(25.0); ``` ## 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`. --- # 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, but got null"`). - 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` with path `[key]`. - 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 get the field name prepended to the error path. Changed/coerced values trigger a rebuild of the output dictionary. - Unknown keys are handled by the object's `UnknownKeyPolicy` or `CatchallSchema` (below). ## 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 `unrecognized_key` and path `[key]`. | ```csharp var strict = Z.Object().Field("name", Z.String()).Build().Strict(); 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](../core-concepts/)). --- # Arrays and Other Schemas ## Arrays `ZodArray` (namespace `ZodSharp.Schemas`) validates `T[]` values. Each element is validated against the element schema; failures carry index paths such as `["0"]`. 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 | | `Max(int maxLength, string? message)` | `too_big` when count > max | | `Length(int length, string? message)` | exact length (both bounds) | | `NonEmpty(string? message)` | `minLength = 1` | ## 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(); ``` ## Enum (string values) `ZodEnum` validates against a set of allowed strings. Failure produces `invalid_enum_value`. ```csharp var schema = Z.Enum("admin", "user", "guest"); ``` ## Native enum `ZodNativeEnum` validates a native `System.Enum` using `Enum.IsDefined`. Failure produces `invalid_enum_value`. ```csharp var schema = Z.Enum(); // Color : struct, Enum ``` ## Literal `ZodLiteral` (where `T : IEquatable`) accepts exactly one value. Failure produces `invalid_literal`. ```csharp var schema = Z.Literal("active"); var schema2 = Z.Literal(42); ``` ## Record `ZodRecord` validates `Dictionary`, validating every value against the value schema with key-prefixed error paths. It always produces a fresh validated dictionary. ```csharp var schema = Z.Record(Z.Number()); ``` ## 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: `invalid_type` on `null`, `invalid_tuple_length` on wrong length, and per-index `invalid_type` with paths like `["[0]"]`. ## 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 ``` --- # 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 is produced (`"Value does not match any of the union options"`) whose `Parameters["errors"]` carries every option's errors. :::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](../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`. ## 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: - No discriminator present → `missing_discriminator`. - Value not among the options → `invalid_discriminator` listing the expected values. - `null` input → `invalid_type`. 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. ## Intersection `ZodIntersection` (created with `Z.Intersection(left, right)` or `.And(other)`) succeeds only when both schemas validate; failures merge both error sets. ```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` (`"Transform failed: {message}"`). 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` (custom `message` or `"Custom validation failed"`). ```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. - `ImmutableArray Path` — the base path. - `AddIssue(string code, string message, string[]? path)` — appends to the base path; throws `ArgumentException` for null/whitespace code or message. - `AddIssue(string message, string[]? path)` — shorthand with code `refinement_failed`. - `Issues` / `HasIssues`. ## 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 (see [Unions and Discriminated Unions](../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 | — | --- # 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. ## Defining a custom rule ```csharp using ZodSharp.Core; namespace MyRules; /// Rejects strings that contain whitespace. public readonly record struct NoWhitespaceRule(string? Message = null) : IValidationRule { 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 GetErrorMessage(in string value) => Message ?? $"Whitespace is not allowed in '{value}'."; } ``` 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 == "validation_failed" // 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](../guarantees-and-limitations/#fluent-rule-methods-mutate-the-receiver). ## 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 constructor arguments are mapped positionally and its named arguments by name (case-insensitive) to the rule's public constructor parameters. 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 bool IsValid(in T value) => !value.Equals(default(T)); public string GetErrorMessage(in T value) => Message ?? "Value must not be empty."; // The rule owns its identity, so callers can pass a per-member error code. string? IZodRule.Code => Code; string? IZodRule.Origin => "value_object"; } ``` ## 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. ## 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 bool IsValid(in string value) => AllowEmpty || value.IndexOf(' ') < 0; public string GetErrorMessage(in string value) => Message ?? "Whitespace is not allowed."; } ``` This produces a `NoWhitespaceAttribute` in the rule's namespace, shaped like: ```csharp /// Validation attribute that applies NoWhitespaceRule. [global::System.AttributeUsage( 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 { public bool AllowEmpty { get; set; } = true; } ``` 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. A parameter named `message` is omitted — use the inherited `ValidationAttribute.ErrorMessage` instead. - The rule must be non-generic, non-nested, and non-abstract, and every parameter type must be a legal attribute-argument type (primitive, `string`, `enum`, `System.Type`). :::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* the 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(...))]`. ::: ## 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 ?? null)); } ``` - **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. - **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. Scalars implement the two-type-parameter contract: ```csharp 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`. Today the check is normally repeated on every scalar: ```csharp // repeated on every Guid scalar partial void OnZodValidate(RefineCtx context) { if (context.Value.Value == Guid.Empty) context.AddIssue("invalid_asset_id", "AssetId must not be empty.", [nameof(Value)]); } ``` :::note Refinements are written as the generator-declared `OnZodValidate` hook, not an `IEnumerable Validate()` method — see [Source Generator](../source-generator/#refinement-hook-onzodvalidate). ::: Type **one** rule on the value object and put the attribute on the **scalar type**: ```csharp // MyRules/NotEmptyRule.cs — a rules library that references Purview.ValueObjects public readonly record struct NotEmptyRule(string? Code = null, string? Message = null) : IValidationRule, IZodRule where TSelf : IScalarValueObject { public bool IsValid(in TSelf value) => value.Value != Guid.Empty; public string GetErrorMessage(in TSelf value) => Message ?? "Value must not be empty."; string? IZodRule.Code => Code; string? IZodRule.Origin => "value_object"; } [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; } } ``` ```csharp // Purview.ChangeOps 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; } } ``` The per-scalar `Validate()` refinements disappear, each scalar keeps its own `Code`/`Message`, and the reported error has an **empty path** because the rule applies to the value object itself: ```text Code = "invalid_asset_id" Message = "AssetId must not be empty." Origin = "value_object" Path = [] ``` Because the rule is closed with `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). :::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 a `IValidationRule` and put it on `Value`. See [Exposing a rule as a DataAnnotations attribute](https://github.com/purview-dev/zodsharp/blob/main/docs/wiki#exposing-a-rule-as-a-dataannotations-attribute) and [Generic rules](https://github.com/purview-dev/zodsharp/blob/main/docs/wiki#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 `[NotEmpty]` on the scalar type (it already knows about ZodSharp through `ZodSchemaMode`). ::: ## 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. | See [Source Generator Diagnostics](../source-generator-diagnostics/) for the full list. ## Related - [Fluent Schema API](../fluent-schema-api/) — `AddRule`/`Rule` live on `ZodType`. - [Source Generator DataAnnotations](../source-generator-dataannotations/) — built-in attribute coverage. - [Guarantees and Limitations](../guarantees-and-limitations/) — allocation and mutation semantics. --- # Compiled Validators and Caching ## CompiledValidator `CompiledValidator` (namespace `ZodSharp.Expressions`) compiles a schema into an expression tree and a delegate. ```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)` | compiled validation delegate | | `CompileParser` | `Func CompileParser(IZodSchema schema)` | returns the value or throws `ZodException` | The expression tree calls `IZodSchema.Validate` on the schema bound as a constant, removing interface dispatch overhead. ## 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 | Schemas are immutable and shareable, so caching identical definitions avoids repeated construction cost across request boundaries. ## 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](../source-generator/). --- # 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`, `ZodLiteral`, `ZodNullable`, and `ZodLazy`. - Objects emit `additionalProperties: false`. - Literals emit `const` (and `type`). - Lazy/recursive schemas emit `$ref` entries under `$defs` (e.g. `#/$defs/__lazyN`). ## 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` → first schema. - String constraints — `minLength`, `maxLength`, `pattern`, and `format` (`email`, `uri`, `uuid`). The `uuid`/`guid` format maps to the versionless `.UUID()`; JSON Schema has no versioned `uuid` format, so a versioned `.UUID(UuidVersion.V7)` schema exports back as plain `format: "uuid"`. - Numeric constraints — `minimum`, `maximum`, `multipleOf`; `integer` additionally applies `.Int()`. - Objects — `required` and optional fields via `Z.Object().Field(...)`. - Arrays — `items`, `minItems`, `maxItems`. ## 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](../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 `JsonException` with a `"Validation failed: ..."` message. The converter strips itself from the options it uses internally to avoid recursion. ## 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)` | ## Failure codes Deserialize/validation failures produce `ValidationError` entries with codes `deserialization_failed` and `json_error` in addition to the schema's own codes. ## 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. --- # 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 `JsonSerializationException` with a `"Validation failed: ..."` message. 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 Deserialize/validation failures produce `ValidationError` entries with codes `deserialization_failed` and `json_error` in addition to the schema's own codes. ## 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 | `System.Text.Json.JsonException` | `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 the deserialize/serialize extension overloads share names, so import exactly one package namespace per file. ::: --- # 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 ```csharp using ZodSharp.AspNetCore; var result = BasketSchema.Validate(basket); if (!result.IsSuccess) { var problem = result.ToHttpValidationProblemDetails(); return Results.ValidationProblem( problem.Errors, extensions: new Dictionary { ["issues"] = problem.Extensions["issues"], }); } ``` | Member | Behaviour | |---|---| | `ToHttpValidationProblemDetails(ValidationResult result, int statusCode = 400)` | `HttpValidationProblemDetails`; error paths flattened to dotted keys (`user.email`, array indexes as `[0]`) | | `ToValidationProblemDetails(ValidationResult result, int statusCode = 400)` | `ValidationProblemDetails` | Both throw `InvalidOperationException` when the result `IsSuccess`. The structured metadata is preserved in the `issues` extension as a `ValidationIssue[]`: ```csharp public sealed class ValidationIssue { public required string Code { get; init; } public string? Category { get; init; } public string? Origin { get; init; } public int? Minimum { get; init; } public int? Maximum { get; init; } public bool? Inclusive { get; init; } public required string[] Path { get; init; } public required string Message { get; init; } public IReadOnlyDictionary? Parameters { get; init; } } ``` ## 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.AspNetCore; try { var parsed = EmailAddressSchema.Parse(rawValue); } catch (ZodException ex) { return Results.ValidationProblem(ex.ToHttpValidationProblemDetails().Errors); } ``` 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)` registers `ZodExceptionHandler`, calls `services.AddProblemDetails(problemDetails)`, and configures `ZodProblemDetailsOptions`: - `ErrorTypeRegistry Registry` — resolves error codes to `ErrorType`s. Defaults to `ErrorTypeRegistry.Default`. - `bool FormatMessages` — when `true`, messages are formatted from the matched `ErrorType.MessageFormat`. - `Func, int>? StatusCodeSelector` — an escape hatch that takes complete control of the response status code. :::note `AddZodSharpProblemDetails` only wires up exception handling and ProblemDetails services — 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: "collection", 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 { "status": 409, "detail": "The aggregate could not be saved.", "errors": { "": ["Aggregate 'agg-123' (of type Invoice) failed to save"] }, "traceId": "...", "aggregateId": "agg-123", "issues": [ { "code": "aggregate_save_failed", "path": [], "message": "Aggregate 'agg-123' (of type Invoice) failed to save", "parameters": { "AggregateId": "agg-123", "AggregateType": "Invoice" } } ] } ``` Mapping rules: - **Status** — the highest matched `ErrorType.HttpStatus` wins; unmapped codes fall back to the default (`400`). Override with `ZodProblemDetailsOptions.StatusCodeSelector`. - **Title / Type / Detail** — taken from the highest-status matched `ErrorType`; otherwise defaulted. - **Message** — `MessageFormat` named placeholders (for example `{AggregateId}`) are substituted from `ValidationError.Parameters`. Placeholders without a matching value are left as-is so templating gaps stay visible. 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 both per-issue in the `issues` extension and merged (camel-cased) into the top-level ProblemDetails extensions for client correlation. `ToHttpValidationProblemDetails` / `ToValidationProblemDetails` accept an `ErrorTypeRegistry` or a `Func` lookup for on-demand mapping, and the same mapping applies to `ValidationResult`. ## 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, applies `options.ConfigureFactory`, and calls `factory.RegisterFromAssembly(assembly)` for each distinct assembly discovered from the configured assembly sources. This auto-discovers source-generated `[assembly: ZodSchemaGenerated(typeof(...))]` registrations. `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. | | `AddZodSharpProblemDetails(...)` | `Purview.ZodSharp.AspNetCore` | `ZodExceptionHandler` + ProblemDetails services only — does **not** register the factory | Mapping thrown `ZodException`s to `ProblemDetails`; pair it with `AddZodSharp` when you also need DI validator resolution. | | `AddZodSchemaOptionsValidator(...)` | core `Purview.ZodSharp` | singleton `IValidateOptions` | Validating options objects; requires a factory registered first via `AddZodSharp` or `AddZodSharpFactory`. | See [Dependency Injection](../dependency-injection/) for the underlying factory and options-validation wiring. --- # Dependency Injection Purview.ZodSharp can resolve validators through an `IZodSchemaFactory` registry, and can validate options objects through `IValidateOptions`. ## IZodSchemaFactory `IZodSchemaFactory` (namespace `ZodSharp.Core`) resolves validators by type: | Member | Behaviour | |---|---| | `Resolve()` | returns `IZodSchemaValidator?` or `null` when unregistered | | `ResolveRequired()` | returns the validator or throws `InvalidOperationException` | | `Validate(T value)` | validates through the registered validator for `T` | | `Register(IZodSchemaValidator)` | registers a validator | | `Register(Type, IZodSchemaValidator)` | non-generic registration | | `TryRegister(IZodSchemaValidator)` | returns `false` if already registered | | `IsRegistered()` | checks for a registration | The default implementation is `ZodSchemaFactory` (concurrent dictionary keyed by `Type`). `IZodSchemaValidator` extends `IZodSchema`, so a resolved validator validates like any other schema. Hand-built schemas can be registered by wrapping them: ```csharp using ZodSharp.Core; factory.Register(new ZodSchemaValidator>(myObjectSchema)); ``` ## Registering source-generated validators The source generator emits `[assembly: ZodSchemaGenerated(typeof({Type}))]` attributes and a `{Type}SchemaValidator` adapter. Scan an assembly to register every generated validator: ```csharp factory.RegisterFromAssembly(typeof(User).Assembly); // or factory.RegisterFromAssembly(); ``` `ZodSchemaFactoryExtensions.RegisterFromAssembly` looks up `{TypeName}SchemaValidator` in the target type's namespace/assembly and registers it. It throws `InvalidOperationException` when the validator type or `IZodSchemaValidator` implementation is missing. ## Registering the factory in DI The core package provides a `Microsoft.Extensions.DependencyInjection` extension: ```csharp builder.Services.AddZodSharpFactory(factory => factory.RegisterFromAssembly(typeof(User).Assembly)); ``` `AddZodSharpFactory(Action? configure = null)` registers a singleton `IZodSchemaFactory` and invokes the configuration callback. It does **not** auto-discover source-generated validators — call `RegisterFromAssembly` yourself inside the callback (or register hand-built validators). :::caution The factory is registered only if one is not already present (`TryAdd` semantics): the **first** `AddZodSharpFactory` (or `AddZodSharp`) call wins, and any later calls — including their `configure` callbacks — are ignored. State is never overwritten, so calling it more than once is safe. ::: The `Purview.ZodSharp.AspNetCore` package offers the richer `AddZodSharp` with exact assembly scans, assembly-graph scans, loaded-assembly scans, and additive assembly-contribution helpers — see [ASP.NET Core Integration](../aspnetcore-integration/). ### Choosing a registration method | Method | Package | Registers | Use when | |---|---|---|---| | `AddZodSharpFactory(configure)` | core `Purview.ZodSharp` | singleton `IZodSchemaFactory` | Any .NET host (console, worker, library, web) where you want manual control — you register validators yourself in the `configure` callback. | | `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 schema assemblies without central coordination. | | `AddZodSharpProblemDetails(...)` | `Purview.ZodSharp.AspNetCore` | `ZodExceptionHandler` + ProblemDetails services only — does **not** register the factory | Mapping thrown `ZodException`s to `ProblemDetails` automatically; pair it with one of the factory registrations above when you also need DI validator resolution. | | `AddZodSchemaOptionsValidator(...)` | core `Purview.ZodSharp` | singleton `IValidateOptions` | Validating options objects; requires a factory registered first via `AddZodSharpFactory` or `AddZodSharp`. | ## Validating options objects Wire generated validators into the options framework so invalid configuration fails fast: ```csharp builder.Services.AddZodSharpFactory(factory => factory.RegisterFromAssembly(typeof(UserOptions).Assembly)); builder.Services.AddZodSchemaOptionsValidator(); ``` `AddZodSchemaOptionsValidator(MissingValidatorBehavior behavior = MissingValidatorBehavior.Throw)` registers a singleton `IValidateOptions` (`ZodSchemaOptionsValidator`) that resolves `IZodSchemaFactory` from DI and validates `T` when options are instantiated. The factory must already be registered — `AddZodSchemaOptionsValidator` only wires up the validator, and can be called once per options type without affecting factory state. `MissingValidatorBehavior` controls what happens when no validator is registered for `T`: - `Throw` (default) — resolves via `ResolveRequired()`, throwing `InvalidOperationException` so a missing schema is surfaced loudly instead of silently skipping validation. - `Ignore` — passes through untouched (`ValidateOptionsResult.Success`). The `OptionsBuilder` extension chains for convenience and composes with the rest of the options framework: ```csharp builder.Services .AddOptions() .Bind(configuration.GetSection("User")) .AddZodSchemaValidator() .ValidateOnStart(); // from Microsoft.Extensions.Hosting — fail at startup, not first access ``` The source generator also auto-generates `IValidateOptions` validators for types whose names end in configurable suffixes — see [Source Generator](../source-generator/). ## Example: consuming a factory ```csharp public sealed class OrderService(IZodSchemaFactory factory) { public void ValidateProduct(Product product) { var result = factory.Validate(product); // ValidationResult } } ``` --- # 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 generated into the `ZodSharp` namespace by the generator itself (assembly `Purview.ZodSharp.SourceGenerators`), so no extra package is needed beyond `Purview.ZodSharp`. ```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`, `Parse`, and (when composition is enabled) `ApplyAnd`, `ApplyOr`, `ApplyRefine` | | `{TypeName}SchemaValidator` | `partial class {TypeName}SchemaValidator : IZodSchemaValidator<{TypeName}>` — DI-friendly adapter with `Validate` / `ValidateAsync`; 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) | | `[assembly: ZodSchemaGenerated(typeof({TypeName}))]` | registration marker consumed by `IZodSchemaFactory` assembly scanning; emitted only for primary, non-nested schemas | ```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"); ``` ## 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. | :::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)`. - 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 the synchronous `Validate`, then awaits the custom method, and merges the error sets. :::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 [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", [nameof(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)`, which is merged with the attribute-rule issues into one result. - 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 | ## What is validated - Properties must be public, non-static, non-indexer. - A property is included when it carries any DataAnnotations attribute or its type is a source-defined complex type with a nested schema. - 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`. See [Source Generator DataAnnotations](../source-generator-dataannotations/) for the attribute coverage and structured issue shape, [Custom Rules](../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]` | nullable property must not be null (strings with `AllowEmptyStrings=false` must be non-empty) | `missing_field` | | `[Length(min, max)]` | min/max size with `too_small`/`too_big`; applies to strings, arrays (incl. jagged/rectangular), and countable collections | `too_small` / `too_big` | | `[StringLength(max)]` / `[StringLength(max, MinimumLength=min)]` | string size limits via direct `.Length` | `too_small` / `too_big` | | `[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 | `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 | — | `[Length]` follows DataAnnotations null semantics: `null` is valid unless `[Required]` is also present. ## 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, `array` for arrays, `collection` for countable/`IEnumerable` collections. - `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 `Origin = "collection"` for countable/`IEnumerable` collections. ::: ## 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. ## Error message customization Honours `ErrorMessage`, or `ErrorMessageResourceName` + `ErrorMessageResourceType`, with `{0}` (display name), `{1}`, and `{2}` (bound) placeholders formatted via `string.Format(CultureInfo.CurrentCulture, ...)`. Providing only one of the resource name/type pair is reported as ZODSGEN005. ## 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](../custom-rules/). --- # 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` is a warning 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 parameter count is not 2 | | ZODSGEN010 | First custom method parameter is not the model type | | ZODSGEN011 | Second custom method parameter is not `CancellationToken` | | 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 | IDs `ZODSGEN002` and `ZODSGEN022`–`ZODSGEN026` are intentionally unused; rule identifiers are never renumbered or re-used. ## 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. --- # Cross-Platform Interop Purview.ZodSharp ships a cross-platform fixture pipeline that proves the C# implementation agrees with TypeScript/Zod. The TypeScript side runs on [Bun](https://bun.sh); the C# side runs under the TUnit test suite. ## The schema Both sides define the same user schema — TypeScript `UserSchema` in `src/ts/schema.ts` and the C# `CrossPlatformUserSchema` in `src/tests/SystemTextJson.UnitTests/CrossPlatformFixtures.cs` (mirrored by `NewtonsoftJson.UnitTests`): | Field | Zod (TS) | Purview.ZodSharp (C#) | |---|---|---| | `name` | `z.string().min(1)` | min-length 1 | | `age` | `z.number().int().min(0).max(120)` | `0..120` integer | | `email` | `z.string().email().optional()` | optional email | | `tags` | `z.array(z.string()).default([])` | string array, default `[]` | ## Fixture generation ```bash bun run generate-fixtures # runs src/ts/generate-fixtures.ts ``` For each of the eight fixture cases (three valid, five invalid) the script: 1. Writes a JSON file to `src/ts/fixtures/{key}.json`. 2. Runs `UserSchema.safeParse(value)` and records the outcome in `src/ts/fixtures/manifest.json` — the authoritative `{ "valid": true|false }` result for every fixture. ## The validation loop 1. **`bun run generate-fixtures`** writes `src/ts/fixtures/*.json` and `manifest.json` (Zod is the authority on validity). 2. **`just test`** (C# TUnit) reads the fixtures and manifest, asserts outcomes match, and writes validated C# output to `src/tests/cross-platform/output/{systemtext,newtonsoft}-valid.json`. 3. **`bun run test`** (vitest) re-checks fixture validity under Zod and parses the C# output JSON — closing the TypeScript ↔ C# loop. **C# cross-platform tests** (`SystemTextCrossPlatformTests`, `NewtonsoftCrossPlatformTests`) load every fixture and assert the C# outcome matches `manifest.json`, deserialize-and-validate valid fixtures, round-trip TS fixture → C# → JSON → C#, and serialize a validated `CrossPlatformUser` into `src/tests/cross-platform/output/{systemtext,newtonsoft}-valid.json`. **Vitest cross-platform tests** (`tests/ts/cross-platform.test.ts`) re-check fixture validity under Zod, verify canonical serialization, and parse every JSON file in `cross-platform/output` with Zod to prove C# output is acceptable to TypeScript/Zod. If the C# output directory is empty, the vitest suite emits a note instructing you to run the C# cross-platform tests first. ## JSON Schema bridge The same interop goal is available without fixtures via JSON Schema: - Export: `Z.ToJsonSchema` (core package) → JSON Schema, or `z.toJSONSchema` on the TypeScript side (Zod v4+). - Import: `Z.FromJsonSchema` (in the System.Text.Json or Newtonsoft.Json package — namespace `ZodSharp.JsonSchema.SystemTextJson` / `ZodSharp.JsonSchema.NewtonsoftJson`). See [JSON Schema Export](../jsonschema-export/) and [JSON Schema Import](../jsonschema-import/). ## Directory layout ``` src/ts/ TypeScript (Zod) schemas + fixture generation src/ts/fixtures/ generated JSON fixtures + manifest.json tests/ts/ vitest cross-platform tests src/tests/cross-platform/ shared output directory for C#-generated JSON ``` --- # Performance Purview.ZodSharp is designed for maximum performance: validation rules are `readonly record struct`s, hot paths use `Span`, and the source generator emits direct typed codegen with no reflection. The committed BenchmarkDotNet suite measures every scenario. ## Running the benchmarks ```bash # All suites dotnet run --project src/src/Benchmarks/Benchmarks.csproj -c Release # or just perf-tests # A specific suite (the `--` passes the filter to BenchmarkDotNet) dotnet run --project src/src/Benchmarks/Benchmarks.csproj -c Release -- --filter "*ObjectPerformanceTests*" ``` Results are written to `BenchmarkDotNet.Artifacts/` (HTML, Markdown, logs) in the project directory. Use `-c Release`; the suite uses `[MemoryDiagnoser]` and a `[SimpleJob]` profile. ## Measurement environment - BenchmarkDotNet 0.15.8, .NET 10.0.12, Windows 11 (10.0.28020.2991). - 13th Gen Intel Core i9-13900KF 3.00 GHz (24 physical / 32 logical cores), X64 RyuJIT x86-64-v3. Numbers are indicative; re-run on your own hardware for local planning. ## Core validation (`BasicPerformanceTests`) | Scenario | Mean | Allocated | |---|---|---| | ValidateBoolean | 2.170 ns | 0 B | | ValidateNumber | 10.702 ns | 0 B | | ValidateString | 41.627 ns | 0 B | | ValidateStringArray | 57.031 ns | 0 B | | ValidateStringWithMultipleRules | 77.313 ns | 0 B | | ValidateNumberWithMultipleRules | 16.427 ns | 0 B | ## Objects (`ObjectPerformanceTests`) | Scenario | Mean | Allocated | |---|---|---| | ValidateSimpleObject (2 fields) | 89.78 ns | 0 B | | ValidateMediumObject (6 fields) | 340.18 ns | 0 B | | ValidateComplexObject (13 fields, nested) | 795.69 ns | 0 B | | ValidateComplexObjectInvalid | 1,152.80 ns | 1,960 B | ## Arrays (`ArrayPerformanceTests`) | Scenario | Mean | Allocated | |---|---|---| | ValidateSmallArray | 124.4 ns | — | | ValidateLargeArray (1000 items) | 11,682.3 ns | — | | ValidateMediumArray (100 items) | 4,585.3 ns | — | | ValidateNumberArray | 12,797.3 ns | — | | ValidateLargeArrayWithComplexSchema | 8,011.8 ns | — | | ValidateLargeArrayInvalid | 11,206.7 ns | 904 B | ## Heavy scenarios (`HeavyPerformanceTests`) | Scenario | Mean | |---|---| | ValidateDeepNestedObject (4 levels) | 198.5 ns | | ValidateWideObject (50 fields) | 2,217.2 ns | | ValidateNestedArray | 177.7 ns | | ValidateStringWithManyRefinements | 114.7 ns | | ValidateLargeObjectWithArrays | 19,578.0 ns | ## Transforms (`TransformPerformanceTests`) | Scenario | Mean | Allocated | |---|---|---| | TransformToLower | 22.26 ns | 48 B | | TransformToUpper | 28.39 ns | 48 B | | TransformTrim | 29.10 ns | 48 B | | TransformChained | 40.71 ns | 96 B | | TransformWithValidation | 78.79 ns | 112 B | ## Unions (`UnionPerformanceTests`) | Scenario | Mean | Allocated | |---|---|---| | ValidateUnion_String (first option) | 48.70 ns | 0 B | | ValidateUnion_Number (second option) | 69.38 ns | 592 B | | ValidateUnion_Boolean (third option) | 99.71 ns | 792 B | | ValidateDiscriminatedUnion_FirstOption | 169.33 ns | 0 B | | ValidateDiscriminatedUnion_SecondOption | 171.90 ns | 0 B | | ValidateUnion_Invalid | 195.87 ns | 1,360 B | :::note Union validations allocate on the failure path and while attempting non-matching options — the string option is free, but matching a later option allocates the error collection from the earlier attempts. Discriminated unions dispatch directly and remain zero-allocation. ::: ## Memory (`MemoryPerformanceTests`) All valid-input paths for the core schema types are zero-allocation: | Scenario | Mean | Ratio | |---|---|---| | ValidateString_Allocations (baseline) | 45.82 ns | 1.00 | | ValidateObject_Allocations | 94.28 ns | 2.06 | | ValidateArray_Allocations | 1,180.24 ns | 25.82 | :::note The string transforms, `ZodString.ValidateSpan` (its result carries a string), and non-first union options are the allocation exceptions. Use `ZodString.IsValidSpan` for an allocation-free span check. See [Guarantees and Limitations](../guarantees-and-limitations/). ::: ## UUID validation (`UuidPerformanceTests`) UUID validation uses a zero-allocation char-scan (version nibble at position 14, variant nibble at position 19) instead of a regex. Measured against the previous compiled regex: | Scenario | Mean | Allocated | |---|---|---| | Rule_CharScan_Valid (`.UUID()`) | 21.99 ns | 0 B | | Rule_LegacyRegex_Valid (previous implementation) | 27.50 ns | 0 B | | Rule_CharScan_Invalid | < 1 ns | 0 B | | Rule_LegacyRegex_Invalid | 14.22 ns | 0 B | | Rule_CharScan_Nil | 20.67 ns | 0 B | | Rule_CharScanV7_Valid (`.UUID(UuidVersion.V7)`) | 20.89 ns | 0 B | | Rule_CharScanV7_Mismatch | 20.11 ns | 0 B | | Schema_UUID_Valid | 29.80 ns | 0 B | | Schema_UUIDV7_Valid | 26.64 ns | 0 B | The char-scan is ~20% faster than the previous regex on the valid path, is version-aware at no extra cost, and rejects wrong-length strings in under a nanosecond. ## Optimizations that make it fast 1. **Struct-based rules** — every rule is a `readonly record struct` implementing `IValidationRule`, so there is no per-validation object allocation. 2. **Span-aware helpers** — `Span`/`ReadOnlySpan` APIs and `ArrayPool`-backed helpers. The shipped string rules implement `IStringValidationRule` (except the URL and Base64 rules, which need a string), so `ZodString.ValidateSpan` validates the span directly and only materialises the value string for its result; `ZodString.IsValidSpan` avoids that allocation entirely. 3. **Compiled validators** — `CompiledValidator.Compile` removes interface dispatch (see [Compiled Validators and Caching](../compiled-validators-and-caching/)). 4. **Source generation** — `[ZodSchema]` emits direct property access and typed equality checks with no reflection (see [Source Generator](../source-generator/)). 5. **Cacheable schema instances** — a fully built schema is safe to cache and share, so `SchemaCache` avoids repeated construction (see [Compiled Validators and Caching](../compiled-validators-and-caching/)). Building is not immutable: the fluent rule methods mutate the receiver. See [Custom Rules](../custom-rules/) for extending rules. The allocations on a successful validation are limited to the string transforms (`ToLower`/`ToUpper`/`Trim`), `ZodString.ValidateSpan` (its result carries a string), and the union non-first-option paths. --- # Guarantees and Limitations ## Guarantees - **Zero-allocation on valid inputs for the core schema types.** Primitives, strings, arrays, and objects (and their generated `[ZodSchema]` validators) validate valid inputs without allocating. See [Performance](../performance/) for the measurements. The exceptions are listed under [Limitations](https://github.com/purview-dev/zodsharp/blob/main/docs/wiki#limitations). - **No reflection on hot paths.** The runtime library uses expression trees only in the opt-in `CompiledValidator` and to compile a one-off discriminator accessor per (type, discriminator) pair for `ZodDiscriminatedUnion`. After that first use, validation runs direct property access; the source generator emits direct typed codegen. - **Deterministic, reviewable generated code.** The `[ZodSchema]` generator output is stable and de-duplicated; there are no scope leaks in emitted code. - **Cross-platform parity.** The C# implementation is exercised against TypeScript/Zod fixtures (see [Cross-Platform Interop](../cross-platform-interop/)). - **Multi-targeting.** Packages target `net8.0`, `net9.0`, and `net10.0`; the source generator targets `netstandard2.0` so it runs in any compiler host. - **A fully built schema is safe to cache and share across threads.** Validation only reads the rule set and `Description`, so once construction is finished a schema can be reused concurrently. Building is *not* immutable — see the next section. ## Limitations ### Fluent rule methods mutate the receiver `Min`, `Max`, `Email`, `Regex`, `UUID`, `StartsWith`, `EndsWith`, `Describe`, and the other fluent rule/description methods append to the receiver's rule list (or set its description) **in place** and return `this`. Only the composition methods (`Transform`, `Refine`, `SuperRefine`, `Pipe`, `Catch`, `Default`, `Prefault`, `And`, `Or`) return a new schema. Finish configuring a schema before sharing it; two pieces of code holding the same instance share its rules. ### Span validation returns a string `ZodString.ValidateSpan(ReadOnlySpan)` validates the span directly whenever every accumulated rule implements `IStringValidationRule`, but its result carries a `string`, so a **successful** validation still allocates once when the value is materialised. Use `ZodString.IsValidSpan(ReadOnlySpan, out ImmutableArray)` when the value is not needed: it is allocation-free on success and only materialises the input when a rule (or a transform) cannot be evaluated over a span. `ValidateSpan` falls back to the string pipeline for schemas that involve string transforms (`Trim`, `ToLower`, `ToUpper`) or rules without a span implementation. ### Rules without a span implementation `UrlRule` (its `Uri.TryCreate` fallback needs a string) and `Base64StringRule` (`Convert.FromBase64String`) do not implement `IStringValidationRule`; adding either to a `ZodString` disables the span fast path for that schema, and `ValidateSpan`/`IsValidSpan` fall back to materialising the input once. ### Size-failure Origin values The generator reports `Origin = "string"` for string size failures, `Origin = "array"` for arrays, and `Origin = "collection"` for countable/`IEnumerable` collections. `ZodArray` reports `"array"`. ### Typed union allocation `ZodTypedUnion`/`ZodUnion` allocate while attempting non-matching options and on failure; discriminated unions backed by dictionaries dispatch directly and stay zero-allocation. ### Rule errors Rules evaluated by the base `Validate` pipeline produce `validation_failed` errors with an empty path. Structured `too_small`/`too_big` issues (with `Origin`, `Minimum`/`Maximum`, and `Inclusive`) are produced by `ZodArray` and by the source generator's size validators. ### String transforms allocate `ToLower`, `ToUpper`, and `Trim` produce new strings on every validation (transform outputs are new strings by nature). ### Number semantics `ZodNumber` operates on `double`. `Int()`, `Safe()`, and `Finite()` are validation rules, not conversions; `.Int()` rejects fractional values rather than rounding them. `Positive()`/`Negative()` are strict (they reject `0`; use `NonNegative()`/`NonPositive()` for inclusive bounds). `MultipleOf` compares the quotient to its nearest integer with a relative tolerance (`1e-12`), so `0.3` is accepted for `MultipleOf(0.1)` while `0.3000000001` is not; NaN and infinity are rejected, and a zero divisor throws `ArgumentException`. ### Enum semantics `ZodEnum` (string values) and `ZodNativeEnum` validate against defined members; they do not parse or convert values. ### JSON Schema import scope `Z.FromJsonSchema` supports **local** `$ref` (`#/...`) references only; external `$ref` targets throw a `NotSupportedException` that names the unsupported reference. The integration packages' `JsonSchemaSerializerOptions` read and write the JSON Schema keyword names (`$schema`, `$id`, `$ref`, `$defs`), so exported definitions round-trip; the core `JsonSchemaDefinition` type itself stays free of serializer annotations. Import types live in package-specific namespaces (`ZodSharp.JsonSchema.SystemTextJson` / `ZodSharp.JsonSchema.NewtonsoftJson`). ### Referencing both JSON integration packages `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`**, because every JSON Schema import type is declared in a package-specific namespace (`ZodSharp.JsonSchema.SystemTextJson`, `ZodSharp.JsonSchema.NewtonsoftJson`) rather than an identical full name. The deserialize/serialize extension methods remain in the `ZodSharp` namespace in both packages, so import exactly one package namespace per file: importing both makes calls such as `schema.DeserializeAndValidate(json)` or `Z.FromJsonSchema(...)` ambiguous at the call site. ## Custom rules Custom rules and their DataAnnotations-style attributes are a first-class extension point. Rules can be attached to a property or to the schema type itself (validating the value object as a unit), and a generic rule can be closed with the target type so one rule serves every scalar of a given shape. See [Custom Rules](../custom-rules/) for the rule contract, the public `AddRule`/`Rule` API, and how to map a rule to a `ValidationAttribute` that the source generator honours. ## Contract vs. underlying libraries - `System.ComponentModel.DataAnnotations` semantics are honoured where documented — e.g. `[Length]` treats `null` as valid unless `[Required]` is present; `[RegularExpression]` runs only on non-empty strings. - Validation failure payloads map to `ProblemDetails`/`HttpValidationProblemDetails` in the ASP.NET Core package (see [ASP.NET Core Integration](../aspnetcore-integration/)). --- # Release Flow Purview.ZodSharp releases are driven by the shared [purview-dev/build](https://github.com/purview-dev/build) pipeline through the GitHub Actions workflows in `.github/workflows/`. ## Versioning The package version comes from `package.json` (`version` field). The current stable line is `2.0.0`; bump `package.json` to release a new version (prerelease builds use a `MAJOR.MINOR.PATCH-prerelease.N` suffix). Package identities are `Purview.ZodSharp.*` (core, SystemTextJson, NewtonsoftJson, AspNetCore). Central package management lives in `Directory.Packages.props`; package versions there are minimum requirements, not exact pins, so the resolved graph can drift. ## Workflows | Workflow | Trigger | Pipeline mode | |---|---|---| | `pr.yml` | pull requests against `main` | `purview-build.yml` with `run-pack: true`, `validate-pack: true` | | `release.yml` | pushes to `main` | `purview-release.yml` with `release-mode: NuGet` | Both consume `purview-build.json`: - `Build` — solution (`src/ZodSharp.slnx`), test root (`src/tests`), patterns (`*Tests.csproj`), filter (`/*/*/*/*`). - `PackValidation` — requires symbol packages/files and validates `RequiredContent` per package (per-TFM DLL + XML, analyzer assemblies and `buildTransitive/Purview.ZodSharp.props` for the core package, `README.md` and `purview-logo-light.png` for every package). - `Release.Mode` — `None` for PR/local runs. ## Pipeline commands ```bash just pipeline-pr # restore, build, lint, tests, pack, validate pack just pipeline-build # restore, build, lint (no tests, no release) just pipeline-tests # pipeline with tests enabled just pipeline-release # pack, publish, GitHub release (NuGet mode) just pipeline-local-release # pack + publish to a local NuGet feed ``` See the `Justfile` for the full command set (`just build`, `just test`, `just lint-check`, `just lint-fix`, `just pack`, `just perf-tests`). ## Commit conventions Commits must follow [Conventional Commits](https://www.conventionalcommits.org/), enforced by Lefthook + Commitlint (`.config/lefthook.yml` and `commitlint.config.mts`). Allowed types: `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`. ## Quality gates - `just build` succeeds with no new warnings/errors. - Relevant tests pass (`just test`). - `just lint-check` (CSharpier) reports no formatting changes. - Packed packages match `purview-build.json` `PackValidation`. - Generated code is deterministic and reviewable. --- # Contributing Contributions are welcome. Please open an issue or pull request against [purview-dev/zodsharp](https://github.com/purview-dev/zodsharp). ## Repository layout ``` src/ src/ ZodSharp/ Core validation library + JSON Schema export (Z.ToJsonSchema) SourceGenerators/ Compile-time [ZodSchema] generator (netstandard2.0, Roslyn) SystemTextJson/ System.Text.Json integration + JSON Schema import NewtonsoftJson/ Newtonsoft.Json integration + JSON Schema import AspNetCore/ ASP.NET Core ProblemDetails integration Examples.CLI/ Usage examples Benchmarks/ BenchmarkDotNet performance suite tests/ *.UnitTests/ TUnit test projects src/ts/ TypeScript (Zod) schema + fixture generation tests/ts/ Vitest cross-platform tests docs/wiki/ This documentation suite ``` ## Commands ```bash just build # dotnet build src/ZodSharp.slnx -c Debug just test # dotnet test src/ZodSharp.slnx -c Debug --treenode-filter "/*/*/*/*" just lint-check # dotnet csharpier check . just lint-fix # dotnet csharpier format . just pack # dotnet pack src/ZodSharp.slnx -c Debug -o artifacts just perf-tests # dotnet run --project src/src/Benchmarks/Benchmarks.csproj -c Release ``` TypeScript tooling uses Bun: ```bash bun install bun run test # vitest run (cross-platform TS tests) bun run generate-fixtures # regenerates src/ts/fixtures/*.json from Zod ``` ## Testing bar - Framework: **TUnit**. - `[Test]` methods take a `CancellationToken cancellationToken` parameter where relevant. - Use `// Arrange`, `// Act`, `// Assert` comments. - Use meaningful, descriptive names (`Action_GivenCondition_ExpectedResult`). - Treat work as incomplete until the relevant tests pass. For source generator tests, use the `Purview.SourceGeneratorFramework.Testing.TUnit` base classes (`TUnitSourceGeneratorTestBase`, `TUnitDiagnosticAnalyzerTestBase`) and assert with `CodeQuery`; test incrementally, not just generated text. ## Documentation This wiki lives in `docs/wiki/`. The site build (purview-dev Astro/Starlight) pulls these files via `github-path` sync and rewrites relative `.md` links into site routes. Conventions: - `_Sidebar.md` declares page order (parsed by the sync, never rendered). - Every page starts with a single `# ` heading (the page title) followed by a one-paragraph description. - Link to other pages with relative `.md` links: `[Getting Started](../)`. - GitHub alert blockquotes (`> [!NOTE]`, `> [!TIP]`, `> [!WARNING]`, `> [!CAUTION]`, `> [!IMPORTANT]`) are converted to Starlight asides. - Relative repository-path links (e.g. `src/src/ZodSharp/Z.cs`) are rewritten to GitHub blob URLs automatically. - Keep the `CrossPlatformUserSchema` (C#) and `UserSchema` (TypeScript) in sync when either changes. ## Formatting Formatting is enforced with CSharpier. `.editorconfig` at the repo root defines style (tabs for code, 2-space for XML/JSON/YAML/markdown).