Documentation section: Hosting
Blazor Integration
Prerelease. These pages describe 3.0.0-prerelease.2 and can differ from the published release. Read the Live 2.4.2 documentation instead.
Blazor Integration
Section titled “Blazor Integration”Purview.ZodSharp.Blazor validates Blazor EditForm models with ZodSharp on Blazor Server, Blazor
WebAssembly and Auto render mode. It targets net11.0 and references the component packages
(Microsoft.AspNetCore.Components.Forms and .Web), never the ASP.NET Core shared framework, so the same
package runs in the browser.
dotnet add package Purview.ZodSharp.Blazordotnet add package Purview.ZodSharp.AspNetCore # server project only, for static-SSR client rules// Program.cs - the server AND the client project.builder.Services.AddZodSharpMyAppSharedSchemas(); // generated, trim-safe registration of [ZodSchema] modelsbuilder.Services.AddZodSharpBlazor();A Blazor Web App typically looks like this:
MyApp/ (server) -> Purview.ZodSharp.Blazor, Purview.ZodSharp.AspNetCoreMyApp.Client/ (WASM) -> Purview.ZodSharp.BlazorMyApp.Shared/ (models) -> Purview.ZodSharp (the generator runs here)AddZodSharpBlazor(configureValidation, configure) registers the Microsoft.Extensions.Validation bridge for type
validation only (AddZodSharpValidation(configureValidation, validateParameters: false)), registers
ZodBlazorOptions and registers TimeProvider.System when no TimeProvider is registered. It never registers an
IZodValidationReportSink.
Two routes
Section titled “Two routes”| Need | Route |
|---|---|
Standard forms, [ZodSchema] models, minimal setup |
1: <DataAnnotationsValidator /> |
| Codes, categories, origins or bounds in the UI; custom message rendering | 2: <ZodValidator /> |
A runtime Z.Object(...) schema for a CLR model |
2: Schema parameter |
| API (server) validation errors on fields | either, plus AddZodProblemDetails |
| Debounced per-keystroke validation | 2: Input + DebounceMilliseconds |
| Field-level re-check of cross-field rules | 2: ValidateNestedOnFieldChange |
Route 1: <DataAnnotationsValidator />
Section titled “Route 1: <DataAnnotationsValidator />”<EditForm Model="order" OnValidSubmit="Save"> <DataAnnotationsValidator /> <InputText @bind-Value="order.Customer.Email" /> <ValidationMessage For="() => order.Customer.Email" /> @foreach (var line in order.Lines) { <InputNumber @bind-Value="line.Quantity" /> <ValidationMessage For="() => line.Quantity" /> } <ValidationSummary /></EditForm>- No
[ValidatableType]and no Microsoft.Extensions.Validation generator are needed for[ZodSchema]models. The ZodSharp resolver supplies the root type info, so Blazor takes its full-validation path: nested objects, collections (Lines[0].Quantity), dictionaries and unions each place their message on the rightFieldIdentifier. In WebAssembly this is the difference between nested validation and top-level-only validation, because the Microsoft.Extensions.Validation generator does not flow to a client project. - A field change runs only that member’s own rules (
ValidateMember), never nested schemas. - Async rules use the platform’s async validation, so
IsValidationPending,IsValidationFaultedand thepending/faultedCSS classes work. - Messages are localized through the shared formatter when
AddLocalization()is configured.
The platform’s message store holds strings only, so Route 1 cannot carry codes or other metadata.
Route 2: <ZodValidator />
Section titled “Route 2: <ZodValidator />”<EditForm Model="order" OnValidSubmit="Save"> <ZodValidator ValidateOn="ZodValidationTrigger.Input" DebounceMilliseconds="250" /> <LiveInputText @bind-Value="order.Customer.Email" /> <ZodValidationMessage For="() => order.Customer.Email" ShowCode="true" /> <ZodValidationSummary GroupByPath="true" /></EditForm>Input debounces changes, so the input has to report one per keystroke. InputText commits on onchange, and a
component binding has no event to choose (@bind-Value:event="oninput" on InputText fails at runtime), so derive
from it:
@* LiveInputText.razor *@@inherits InputText<input @attributes="AdditionalAttributes" class="@CssClass" @bind="CurrentValueAsString" @bind:event="oninput" />| Parameter | Default | Meaning |
|---|---|---|
Schema |
null |
An explicit IZodUntypedValidator for the model (see ZodModelSchema); otherwise the registered validator. |
ValidateOn |
Change |
Submit (field changes don’t validate), Change, Input. Submit-time validation always runs. |
DebounceMilliseconds |
0 |
Delay for Input-triggered field validation; each change supersedes and cancels the pending one. |
EnableClientValidation |
true |
Emit static-SSR client rules, exactly as DataAnnotationsValidator does. |
ValidateNestedOnFieldChange |
false |
Re-validate the whole object that owns the changed field and update all its fields. |
Behaviour:
- Form validation uses
ValidationRequestedEventArgs.AddAsyncValidator; field validation usesEditContext.RegisterAsyncFieldValidator. Pending, faulted, cancellation and supersession come from the platform. Store updates are marshalled throughInvokeAsync. - A field change on a member the validator can validate on its own runs only that member’s rules. For runtime schemas without member validation the whole model is validated and only the changed field’s errors are kept; other fields’ messages are left exactly as they were.
- The full
ValidationErrorof every message is kept inEditContext.Properties[ZodValidator.ErrorsKey]and read withGetZodErrors(field),GetZodErrors(() => model.Member)orGetZodErrors(). - A validator that throws is logged through
ILogger<ZodValidator>with the field, and the field (or form) is marked faulted. - With a
DataAnnotationsValidatoron the same form,ZodValidatorrecords the full errors for the types the bridge validates but leaves the messages toDataAnnotationsValidator, so nothing is duplicated, and logs one warning (ZodBlazorOptions.WarnOnValidatorCoexistence). ADataAnnotationsValidatorwith client validation disabled is not detected.
Runtime schemas
Section titled “Runtime schemas”var schema = Z.Object() .Field("Reference", Z.String().Min(3)) .Field("Customer", Z.Object().Field("Email", Z.String().Email()).Build()) .Build();IZodUntypedValidator validator = ZodModelSchema.For<Order>(schema);ZodModelSchema.For<TModel>(ZodObject) reads the model’s public properties (and nested objects) into the key/value
shape the object schema validates and places the errors on the CLR fields. Collections are passed as they are.
ZodModelSchema.For<TModel>(IZodSchema<TModel, TModel>) adapts a schema of the model’s own type.
Value objects and other struct members
Section titled “Value objects and other struct members”A struct cannot be a FieldIdentifier model (Blazor rejects a value-type model, and a boxed copy is not the instance
the form binds), so an error located inside a struct member is placed on the nearest reference-type owner and its
member. Given a scalar value object on the form model:
[Scalar][ZodSchema]public readonly partial record struct EmailAddress{ [Required] [EmailAddress] public string Value { get; }}
[ZodSchema]public sealed class Registration{ public EmailAddress Email { get; set; }}the error at Email.Value is shown by <ValidationMessage For="() => model.Email" /> (or ZodValidationMessage),
next to the value object’s input. Both routes, both hosts (Server and WebAssembly) and AddZodProblemDetails place
it the same way; GetZodErrors(() => model.Email) returns it, with its full path Email.Value unchanged. A struct
inside a collection (Stops[1].Latitude) is placed on the collection member (Stops).
A scalar value object’s own member is an implementation detail, so its messages use the owning member’s display
name: "Email is required", "Field 'Contact email' must be a valid email address." with
[Display(Name = "Contact email")], rather than "Value is required". The displayName parameter carries the
owner’s name too; member and declaringType still identify the value object’s member, for localization. A rule’s
own message text (one not produced from its template) is kept. A struct that is not a scalar keeps its members’
names ("Field 'Latitude' must be greater than or equal to -90 and less than or equal to 90."), since it may have several.
Message components
Section titled “Message components”<ZodValidationMessage For="..." /> and <ZodValidationSummary /> render the same markup and CSS classes as
ValidationMessage and ValidationSummary (interactive and static, including the static-SSR data-valmsg-for
placeholders), plus data-code and data-category when the full error is known.
ZodValidationMessage:ShowCode(aspan.validation-code),Template(RenderFragment<ValidationError>).ZodValidationSummary:Model/ShowModelOnly(model-level messages only),GroupByPath(li.validation-group[data-path]),ShowUnionDetails(each union option’s issues).
With Route 1 both fall back to plain messages.
Server errors onto forms
Section titled “Server errors onto forms”var response = await http.PostAsJsonAsync("/orders", order);if (await response.TryReadZodValidationProblemAsync() is { } problem) editContext.AddZodProblemDetails(problem);-
TryReadZodValidationProblemAsyncreads an unsuccessful JSON response witherrorsorissues- the contract ASP.NET Core Integration writes - using source-generated JSON metadata. It andZodValidationProblemDetails(namespaceZodSharp.Http) come fromPurview.ZodSharp.Http, which this package references; see HttpClient Integration for validating request and response bodies too. -
AddZodProblemDetailsplaces issues by their typedsegments, soLines[0].Quantitylands onorder.Lines[0].Quantity; withoutissuesit parses the dottederrorskeys. Camel-cased member names match case-insensitively, and dictionary keys resolve. When aZodValidatoris on the form its validator resolves the paths. -
Server errors replace the previous server errors and are cleared per field when the field changes (
clearOnFieldChange: falsekeeps them).ClearZodErrors()removes every ZodSharp error. -
AddZodErrors(errors)places errors produced outside the form the same way - for example thosePostAsJsonValidatedAsyncreturns when it refuses to send an invalid value:var sent = await http.PostAsJsonValidatedAsync("/orders", order,new OrderSchemaValidator(), AppJsonContext.Default.Order, services);if (!sent.IsSuccess)editContext.AddZodErrors(sent.Errors);else if (await sent.Value!.TryReadZodValidationProblemAsync() is { } problem)editContext.AddZodProblemDetails(problem);It shares
AddZodProblemDetails’s store and rules: each call replaces the previous one’s errors, they clear per field on change, andGetZodErrorsreturns the full errors.
Static SSR client-side validation
Section titled “Static SSR client-side validation”// Server Program.csbuilder.Services.AddRazorComponents();builder.Services.AddZodSharpClientValidation(); // Purview.ZodSharp.AspNetCore; either order worksA statically rendered form with <DataAnnotationsValidator /> (or <ZodValidator />) then carries, beside ASP.NET
Core’s DataAnnotations rules, client rules for:
| Attribute | Client validator |
|---|---|
[StartsWith], [EndsWith] (ordinal or ordinal-ignore-case) |
zod-startswith, zod-endswith |
[Includes] |
zod-includes |
[UUID] (optionally versioned) |
zod-uuid |
[Cuid2], [ULID] |
zod-cuid2, zod-ulid |
[IPAddress], [Cidr] |
zod-ip, zod-cidr |
[Regex] |
zod-regex (a match anywhere, like Regex.IsMatch) |
[Int], [MultipleOf] |
zod-int, zod-multipleof |
[MinValue], [MaxValue], [GreaterThanOrEqual], [LessThanOrEqual] |
zod-min, zod-max |
[GreaterThan], [LessThan] |
zod-gt, zod-lt |
Custom rule attributes get the same client rules when their rule reports the same IZodRule metadata (format
parameters and bounds); a custom attribute can also implement IClientValidationRuleProvider with
ZodClientValidationRules.For(rule). Runtime schemas have no attributes: register them with
AddZodSharpClientValidation(o => o.AddSchema<Order>(schema)) and the rules are derived from the schema’s JSON
Schema (minLength, maxLength, pattern, bounds, multipleOf, integer, uuid/ipv4/ipv6).
The browser validators live in _content/Purview.ZodSharp.Blazor/zodsharp-validation.js and register through a
Blazor JavaScript initializer, so no <script> tag is needed. Each mirrors its C# rule; a parity suite runs them
against the C# verdicts on the same inputs.
How it composes, and its limits
Section titled “How it composes, and its limits”- ASP.NET Core 11 exposes
ClientValidationProviderbut no API to add rules to its internal DataAnnotations provider.AddZodSharpClientValidationtherefore decorates the registered provider: it renders the decorated provider’s fragment, reads itsdata-rulesJSON and merges the ZodSharp rules into the same field entries. The payload format is undocumented; contract tests pin it. AddRazorComponentsregisters the default withTryAdd.AddZodSharpClientValidationreplaces whatever is registered (or, when called first, the defaultAddRazorComponentswould register), so either order works. A provider registered after it replaces ZodSharp’s; in Development a startup check logs a warning.- ASP.NET Core only emits its own DataAnnotations rules for fields the validation resolver reports as validatable, which ZodSharp’s generated validators do for every member with rules.
- Async, service-aware and culture-sensitive rules, and rules without a JavaScript equivalent, are server-only. .NET and JavaScript regular expressions differ at the edges.
- Client rules only improve the user experience. The server always validates the posted form.
Blazor Server
Section titled “Blazor Server”- Validation runs next to the app’s services, so async rules can query a database.
ValidateContext.ServiceProvider(and the servicesZodValidatorpasses) is the circuit scope, which lives as long as the connection: a scopedDbContextresolved there is shared by every validation in the circuit. UseIDbContextFactory<T>or an explicitly created scope inside async validators. - Every field change is a SignalR round trip anyway; debounce expensive async checks with
Input+DebounceMilliseconds. AddZodSharpBlazorregisters noIZodValidationReportSink. If the same app callsAddZodSharpProblemDetails(minimal APIs), that sink stores errors on the current HTTP request only and discards writes made from an interactive circuit, so neither route accumulates anything for the life of a circuit.- Prerendering: don’t validate in
OnInitialized; it runs once per render phase. UsePersistentComponentStatefor server-only error state if needed.
Blazor WebAssembly
Section titled “Blazor WebAssembly”- Validation in the browser is for UX; the server stays authoritative.
- Registration should be the generated
AddZodSharp{Assembly}Schemas();AddZodSharpAssembly*andRegisterFromAssemblyare[RequiresUnreferencedCode]. - Generated validators resolve members and validate single members without reflection. Reading problem details uses
a source-generated
JsonSerializerContext. The package isIsTrimmableandIsAotCompatible. - Codes, parameters and default messages are culture-invariant, so
InvariantGlobalizationchanges nothing; localized messages needAddLocalization(), satellite assemblies and a startup culture. - WebAssembly is single-threaded: the obsolete synchronous
EditContext.Validate()throws for models with async-only rules instead of blocking. UseValidateAsync.
Sample
Section titled “Sample”src/src/Examples.Blazor (server) and src/src/Examples.Blazor.Client (WebAssembly) are a Blazor Web App with every
render mode enabled. Both validate the [ZodSchema] models of src/src/Examples.Shared - the same types
Examples.MinimalApi binds - and both Program.cs files register them the same way:
builder.Services.AddZodSharpZodSharpExamplesSharedSchemas(); // generated, trim-safebuilder.Services.AddZodSharpBlazor();| Page | Render mode | Shows |
|---|---|---|
/orders |
Interactive Server | Route 1: <DataAnnotationsValidator /> over CreateOrderRequest - a nested ShippingAddress, a list of Lines and a dictionary of AdditionalAddresses - with field-level validation and each message next to its input. |
/register |
Interactive WebAssembly | Route 2: RegisterCustomerForm - <ZodValidator ValidateOn="Change | Input" DebounceMilliseconds="300">, ZodValidationMessage ShowCode, the service-aware async email-uniqueness rule (its registry calls the server from the browser) with a pending indicator from IsValidationPending. It registers through CustomerAPIClient (HttpClient Integration): PostAsJsonValidatedAsync with the browser’s services, ReadFromJsonAndValidateAsync for the response, and a button that posts without validating so the server’s ProblemDetails come back through TryReadZodValidationProblemAsync onto the fields with AddZodErrors. |
/register-server |
Interactive Server | The same RegisterCustomerForm on the server. CustomerAPIClient is a typed client from IHttpClientFactory, created in the circuit’s scope, calling the API service-to-service; the uniqueness rule resolves the server’s own registry. |
/returns |
Static SSR | FormName + [SupplyParameterFromForm], server validation of the posted form, and browser-side checks of [StartsWith] and [UUID] from AddZodSharpClientValidation(). |
The server also exposes the minimal API the WebAssembly form posts to (POST /api/customers, validated by the .NET 11
pipeline because the server also calls AddZodSharpValidation()) and a culture switch (/culture/set?culture=fr). One
StringLocalizerZodMessageFormatter over SampleMessages.fr.resx localizes the ProblemDetails, both form routes and
the WebAssembly client, which adopts the server-rendered <html lang> at startup so RunAsync loads the French
satellite assembly.
dotnet run --project src/src/Examples.Blazorjust wasm-size # trimmed WebAssembly publish (fails on IL2xxx/IL3xxx) + the size budgetVerification
Section titled “Verification”Examples.Blazor.IntegrationTestshosts the sample in memory (WebApplicationFactory): the static-SSR page’sdata-rulescarryzod-startswithandzod-uuid, an invalid nested and collection POST re-renders each message next to its input (AngleSharp), andPOST /api/customersreturns the ZodValidationProblemDetails contract, localized forAccept-Language: fr.CustomerAPIClientTestsresolves the server’sCustomerAPIClientregistration and drives it against the API: a valid registration, an invalid one and a taken address rejected without a request, and an unvalidated post returning the server’s errors.- The
wasm-trimmedPR job publishes the client trimmed with-warnaserrorand checks the Brotli size of eachPurview.ZodSharp*assembly against.config/wasm-size-baseline.json(more than 10% growth fails;scripts/check-wasm-size.ps1 -Updaterewrites the baseline after an intended change). - The nightly
wasm-nightly.ymlworkflow publishes the client withRunAOTCompilation=true(thewasm-toolsworkload) and runstests/e2e/blazor-sample.e2e.mjsagainst the published sample in Chromium, including a registration from each of/registerand/register-server. Run it locally withnpm install --no-save --no-package-lock playwright-core@1, the published sample onhttp://127.0.0.1:5090, andE2E_BROWSER_CHANNEL=msedge node tests/e2e/blazor-sample.e2e.mjs. - The client does not reference Microsoft.Extensions.Validation directly and has no
[ValidatableType]; nested, collection and dictionary validation still works in the browser because the ZodSharp resolver supplies the type info. - In a trimmed or AOT publish the
ZodSharp.Reflection.IsSupportedfeature switch is off. Generated[ZodSchema]models place every error exactly; a runtime schema’s errors attach to the nearest object the trimmed app can still resolve.