ASP.NET Core 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.
ASP.NET Core Integration
Section titled “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
Section titled “Install”dotnet add package Purview.ZodSharp.AspNetCoreProblemDetails
Section titled “ProblemDetails”Every path that turns ZodSharp errors into ProblemDetails delegates to one writer, ZodProblemDetailsWriter:
the endpoint filter, ZodExceptionHandler, ZodTypedResults, the .NET 11 pipeline enrichment, and every
ToHttpValidationProblemDetails/ToValidationProblemDetails/ToValidationProblem overload. Given the same options
they all produce the same body (see The error contract); use an overload that takes the
request (HttpContext) so the options the application registers apply.
using ZodSharp;
app.MapPost("/baskets", (Basket basket, HttpContext httpContext) =>{ var result = BasketSchema.Validate(basket); return result.IsSuccess ? Results.Ok(result.Value) : result.ToValidationProblem(httpContext);});The extensions are in the ZodSharp namespace and exist for both ValidationResult<T> and ZodException:
| Member | Behaviour |
|---|---|
ToHttpValidationProblemDetails(HttpContext httpContext) |
Uses the writer, and therefore the options, registered in the request services (ValidationResult<T> only). Prefer this in a request. |
ToHttpValidationProblemDetails(ZodProblemDetailsOptions options, HttpContext? httpContext = null) |
Uses the supplied options; the form to use outside a request |
ToValidationProblemDetails(...) |
The same overloads, returning MVC ValidationProblemDetails with every extension copied |
ToValidationProblem(HttpContext httpContext) |
An IResult written by the registered writer, keeping the computed status (prefer this) |
ToHttpValidationProblemDetails(int statusCode = 400) |
Obsolete. Built-in defaults, no ErrorTypes, messages unformatted |
ToHttpValidationProblemDetails(ErrorTypeRegistry registry, int statusCode = 400) |
Obsolete. Resolves ErrorTypes from registry and formats their messages; every other option at its default |
ToHttpValidationProblemDetails(Func<string, ErrorType?> lookup, int statusCode = 400) |
Obsolete. Resolves ErrorTypes through lookup; every other option at its default |
ToValidationProblem() |
Obsolete. An IResult with built-in defaults (ValidationResult<T> only) |
The ValidationResult<T> overloads throw InvalidOperationException when the result IsSuccess.
The option-less overloads
Section titled “The option-less overloads”The 2.x overloads that take only a status code, an ErrorTypeRegistry or a lookup (and ToValidationProblem()) take
neither options nor a request, so they cannot see the ZodProblemDetailsOptions an application configures
(AddZodSharpProblemDetails, Configure<ZodProblemDetailsOptions>): FormatMessages, PathNaming, IncludeIssues,
MergeParametersIntoExtensions, StatusCodeSelector and the configured registry are ignored, so their body can differ
from the endpoint filter and the exception handler. There is no ambient options instance they could read without a
request, so they keep their 2.x output unchanged and are marked [Obsolete] (CS0618). Each one is exactly equivalent
to the options overload with these options:
| Obsolete call | Equivalent |
|---|---|
ToHttpValidationProblemDetails(statusCode) |
ToHttpValidationProblemDetails(new ZodProblemDetailsOptions { Registry = new(), FormatMessages = false, DefaultStatusCode = statusCode }) |
ToHttpValidationProblemDetails(registry, statusCode) |
ToHttpValidationProblemDetails(new ZodProblemDetailsOptions { Registry = registry, DefaultStatusCode = statusCode }) |
ToHttpValidationProblemDetails(lookup, statusCode) |
ToHttpValidationProblemDetails(new ZodProblemDetailsOptions { ErrorTypeResolver = lookup, DefaultStatusCode = statusCode }) |
ToValidationProblem() |
ToValidationProblem(httpContext) (applies the registered options) |
In a request, move to the HttpContext overload; elsewhere (a background job, a test), pass the options you want.
Typed results
Section titled “Typed results”ZodTypedResults produces the same body for handlers that declare their results:
using Microsoft.AspNetCore.Http.HttpResults;using ZodSharp.AspNetCore;
app.MapPost("/orders", Results<Ok<Order>, ValidationProblem> (Order order, HttpContext httpContext) =>{ var result = OrderSchema.Validate(order); return result.IsSuccess ? TypedResults.Ok(result.Value) : ZodTypedResults.ValidationProblem(result, httpContext);});| Member | Returns |
|---|---|
ZodTypedResults.ValidationProblem(result, httpContext) / ValidationProblem(exception, httpContext) |
ASP.NET Core’s ValidationProblem, for Results<...> unions. It is always 400, even when a mapped ErrorType yields another status. |
ZodTypedResults.Problem(result, httpContext) / Problem(exception, httpContext) |
ZodProblemDetailsResult, which keeps the computed status (for example a mapped 409) and writes through IProblemDetailsService. |
ValidationIssue
Section titled “ValidationIssue”The issues extension is a ValidationIssue[]. ValidationIssue is a readonly record struct in
ZodSharp.AspNetCore, and a safe projection of ValidationError:
| JSON property | CLR member | Value |
|---|---|---|
path |
DottedPath |
The errors key (Items[0].Quantity); empty for the root |
segments |
Segments |
Zod’s path shape: strings for members and keys, integers for indexes |
code |
Code |
The ZodSharp code |
category |
Category |
The Zod issue code for built-in errors (too_small, invalid_type, …) |
origin |
Origin |
string, number, array, date, … |
minimum/maximum |
Lower/Upper |
From the error’s typed bounds: JSON numbers, or ISO-8601 strings for dates |
inclusive |
Inclusive |
Whether the bound is inclusive |
message |
Message |
The message, rendered by the registered IZodMessageFormatter |
parameters |
SafeParameters |
Camel-cased, restricted to invariant scalars and arrays of them |
unionErrors |
UnionErrors |
For a failed union, each option’s issues, with paths relative to the union |
The CLR-only members Path (string[]), Minimum, Maximum (int?) and Parameters (the raw
ErrorTypeParameters) keep their 2.x meaning for in-process code and are not serialized.
The error contract
Section titled “The error contract”A request with a short CustomerName, an empty Lines[0].Sku, and out-of-range quantities in Lines[0] and
ByCode["en"] produces this body. It is real output from the .NET 11 pipeline with AddZodSharpProblemDetails().
The WithZodSharpValidation() filter produces the same body.
{ "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", "title": "One or more validation errors occurred.", "status": 400, "errors": { "CustomerName": ["Field 'CustomerName' must contain at least 3 characters."], "Lines[0].Sku": ["Sku is required"], "Lines[0].Quantity": ["Field 'Quantity' must be greater than or equal to 1 and less than or equal to 100."], "ByCode[en].Quantity": ["Field 'Quantity' must be greater than or equal to 1 and less than or equal to 100."] }, "issues": [ { "path": "CustomerName", "segments": ["CustomerName"], "code": "too_small", "category": "too_small", "origin": "string", "minimum": 3, "inclusive": true, "message": "Field 'CustomerName' must contain at least 3 characters.", "parameters": { "count": 2, "displayName": "CustomerName", "declaringType": "Order", "member": "CustomerName" } }, { "path": "Lines[0].Sku", "segments": ["Lines", 0, "Sku"], "code": "missing_field", "category": "invalid_type", "message": "Sku is required", "parameters": { "displayName": "Sku", "declaringType": "OrderLine", "member": "Sku" } }, { "path": "Lines[0].Quantity", "segments": ["Lines", 0, "Quantity"], "code": "invalid_range", "category": "too_small", "origin": "number", "minimum": 1, "maximum": 100, "inclusive": true, "message": "Field 'Quantity' must be greater than or equal to 1 and less than or equal to 100.", "parameters": { "minimum": "1", "maximum": "100", "displayName": "Quantity", "declaringType": "OrderLine", "member": "Quantity" } }, { "path": "ByCode[en].Quantity", "segments": ["ByCode", "en", "Quantity"], "code": "invalid_range", "category": "too_big", "origin": "number", "minimum": 1, "maximum": 100, "inclusive": true, "message": "Field 'Quantity' must be greater than or equal to 1 and less than or equal to 100.", "parameters": { "minimum": "1", "maximum": "100", "displayName": "Quantity", "declaringType": "OrderLine", "member": "Quantity" } } ], "count": 2, "displayName": "Quantity", "declaringType": "OrderLine", "member": "Quantity", "minimum": "1", "maximum": "100", "traceId": "00-3b87d0e8fa5f0b442bf75ffb97d44b3d-103774b7a225269d-02"}A failed union reports its options’ issues under unionErrors:
{ "path": "Contact", "segments": ["Contact"], "code": "invalid_union", "category": "invalid_union", "message": "Value does not match any of the union options (string, number)", "parameters": { "options": ["string", "number"] }, "unionErrors": [ [{ "path": "", "segments": [], "code": "invalid_string", "category": "invalid_format", "origin": "string", "message": "Invalid email format", "parameters": { "format": "email" } }], [{ "path": "", "segments": [], "code": "invalid_type", "category": "invalid_type", "message": "Expected number, received string", "parameters": { "expected": "number", "received": "string" } }] ]}The contract:
- Keys.
errorskeys andissues[].pathareValidationError.FullPath.ToDottedString(): members joined with., indexes as[0], and keys as[en]or, when ambiguous, quoted (Map["0"],Map["a.b"]). The root is"". See Core Concepts. By default member names are the C# names, matching Microsoft.Extensions.Validation and MVC model state.PathNaming = ZodPathNaming.Jsonapplies the minimal-APIJsonOptionsnaming policy. codeandcategory.codeis ZodSharp’s specific identity (invalid_range).categoryis the Zod issue code (too_small), so a client can treat ZodSharp and Zod issues alike.- Top-level parameters. Parameters are written per issue only. With
MergeParametersIntoExtensions = true(the 2.4 behaviour, off by default) every issue’s parameters are also merged into the top-level extensions, and the last issue wins. A parameter whose camel-cased name is reserved (type,title,status,detail,instance,errors,issues,traceId) is written asparam_{name}. - No implementation details.
issues[].parametersleaves outdeclaringType(a full CLR type name) andmember, which generated validators attach for localization: they describe the server’s types, not the request, and the issue’spathalready names the member.displayNameis kept. SetIncludeDiagnosticParameters = trueto send them. traceId. It is present on every path:Activity.Current?.Id, elseHttpContext.TraceIdentifier.- No input values. Messages and parameters never contain the input unless
ZodErrorOptions.IncludeReceivedValuesis enabled. See Core Concepts. - Messages. They come from the registered
IZodMessageFormatterinCultureInfo.CurrentUICulture. See Localization. - OpenAPI. The body is described by the
ZodValidationProblemDetailsandZodValidationIssuecomponent schemas (see OpenAPI Integration).
Minimal API validation
Section titled “Minimal API validation”There are two ways to validate minimal-API parameters: the WithZodSharpValidation() endpoint filter, on every
target framework, and on .NET 11 the built-in validation pipeline
(below).
using ZodSharp.AspNetCore;
// Every endpoint of a group: each bound parameter with a registered validator is validated.var api = app.MapGroup("/api").WithZodSharpValidation(options =>{ options.ThrowIfNoValidator = false; options.OnBindingFailure = ZodBindingFailureBehavior.PassThrough;});
// One endpoint, one type: every bound argument assignable to UserDto is validated.app.MapPost("/users", (UserDto dto) => dto).WithZodSharpValidation<UserDto>();WithZodSharpValidation() works on any IEndpointConventionBuilder, including route groups:
- Which parameters. They are selected when the endpoint is built. Services (
[FromServices], keyed services, anything the container resolves),HttpContext,HttpRequest,HttpResponse,ClaimsPrincipal,CancellationToken, form files, streams and[SkipValidation]parameters are skipped.[FromBody],[FromRoute],[FromQuery],[FromHeader]and[FromForm]force a parameter to be considered. - How. Every selected parameter is validated through
IZodSchemaFactory.TryGetUntypedValidator. - Error keys. They follow Microsoft.Extensions.Validation: a complex parameter’s errors are not prefixed
(
Name,Address.Street), a sequence parameter’s are (items[0].Name), and an error about the value itself is keyed by the parameter name. - Response metadata. The endpoint advertises a
400application/problem+jsonresponse (statusProducesStatusCode) for OpenAPI. - The body. The filter honours
ZodProblemDetailsOptionsand theErrorTypeRegistry, and writes throughIProblemDetailsService.
WithZodSharpValidation<T>() (on a RouteHandlerBuilder) validates every bound argument assignable to T. The
validator is resolved when the endpoint is built. When no IZodSchemaFactory or no validator for T is
registered, building the endpoint fails with a message naming the handler and the parameter.
ZodEndpointValidationOptions |
Default | Effect |
|---|---|---|
ThrowIfNoValidator |
false |
Fail endpoint building when a complex bound parameter has no registered validator. |
OnBindingFailure |
PassThrough |
What to do when binding already failed (see below). |
ProducesStatusCode |
400 |
The status advertised in response metadata; set it when a mapped ErrorType changes the status. |
On .NET 11, endpoint filters also run after a route, query, header or form binding failure, with the status already set to 400 and the argument left at its default. A malformed JSON body is answered by ASP.NET Core itself and never reaches a filter. Before .NET 11 this option has no effect.
ZodBindingFailureBehavior |
Effect |
|---|---|
PassThrough |
Do not validate; ASP.NET Core’s binding-failure response stands. |
ConvertToProblemDetails |
Write the ZodSharp contract instead: an invalid_type issue (invalid_json for a body) for each unbound validated parameter, or one root invalid_type issue when the failed parameter cannot be identified. |
.NET 11: the built-in validation pipeline
Section titled “.NET 11: the built-in validation pipeline”On .NET 11, ASP.NET Core validates minimal-API parameters itself through Microsoft.Extensions.Validation.
AddZodSharpValidation() (package Purview.ZodSharp.Extensions.Validation) makes ZodSharp the first
validation resolver, and on net11.0 AddZodSharpProblemDetails() turns the pipeline’s response into the
full ZodSharp contract:
builder.Services.AddZodSharp(options => options.ScanAssemblies.Add(typeof(Order).Assembly));builder.Services.AddZodSharpValidation();builder.Services.AddValidation(); // when DataAnnotations parameters/types or union bodies are usedbuilder.Services.AddZodSharpProblemDetails();
var app = builder.Build();
var orders = app.MapGroup("/orders"); // every endpoint is validated - no per-endpoint callorders.MapPost("/", (Order order, [Range(1, 100)] int page) => TypedResults.Ok());orders.MapPost("/import", (ImportRequest request) => TypedResults.Ok()).DisableValidation();How it works:
AddZodSharpProblemDetails()registers the bridge’sIZodValidationReportSink. It appends every full-fidelity ZodSharp error to aZodRequestValidationReportstored on the current request (HttpContext.Features), so the report lives and dies with the request. Writes made outside an HTTP request that is still being handled (an interactive Blazor circuit, WebAssembly, a background service) are discarded.- It composes
ProblemDetailsOptions.CustomizeProblemDetailswith a post-configure step. The ZodSharp callback runs first, then whatever callback the application configured, whether before or after the call or through itsproblemDetailsargument. The application can therefore still change the result. A laterPostConfigurethat replaces the callback outright removes the ZodSharp one. - It calls
AddProblemDetails(), without which the callback never runs.
In the callback, for an HttpValidationProblemDetails:
| Report | Result |
|---|---|
| ZodSharp errors reported | ZodProblemDetailsWriter.Apply rebuilds errors and issues. ZodSharp keys are replaced and DataAnnotations entries are kept, each with a dataannotations issue. status, title, type and detail come from the highest ErrorType, then StatusCodeSelector and StatusCodeOverride are applied. The status is mirrored to the response. |
| Empty (DataAnnotations-only failure) | The payload is left untouched, apart from the issues projection (ProjectDataAnnotationsIssues, default true). |
The body is byte-identical (apart from traceId) to the one the endpoint filter, ZodExceptionHandler and
ToValidationProblem(httpContext) produce.
Notes:
- Localization: register
AddLocalization()and anIZodMessageFormatter, for examplenew StringLocalizerZodMessageFormatter(factory, sharedResourceType: typeof(ValidationMessages)). Messages followAccept-Language(withUseRequestLocalization); codes and paths are unchanged. See Localization. - The bridge: resolver placement,
ZodReportand non-HTTP validation are covered in Microsoft.Extensions.Validation. - Unions: a union parameter is claimed by the runtime parameter resolver, which the application’s
AddValidation()call adds. The active case is reported underValue(Value.Email). - Double validation:
WithZodSharpValidation()on an endpoint the pipeline validates skips the parameters the bridge covers, logging that once atDebugwhen the endpoint is built. On aDisableValidation()endpoint it validates as usual. - OpenAPI: the
Purview.ZodSharp.AspNetCore.OpenApioperation transformer adds the validation problem response for endpoints with a ZodSharp-known parameter. No endpoint metadata is added by the pipeline path. See OpenAPI Integration.
WithZodSharpValidation() filter |
Built-in pipeline | |
|---|---|---|
| Frameworks | net10.0 and net11.0 | net11.0 |
| Scope | endpoints and groups you mark | every endpoint; opt out with DisableValidation() |
| DataAnnotations parameters | not validated | validated and merged into one body |
| Binding failures | OnBindingFailure |
ASP.NET Core’s own response |
| Response metadata | adds the 400 response | the OpenAPI transformer |
Exception handling
Section titled “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:
using ZodSharp;
try{ var parsed = EmailAddressSchema.Parse(rawValue);}catch (ZodException ex){ return ex.ToValidationProblem(httpContext);}Or handled automatically by an IExceptionHandler. Register it and ensure UseExceptionHandler() is in
the pipeline:
builder.Services.AddZodSharpProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();AddZodSharpProblemDetails(Action<ZodProblemDetailsOptions>? configure = null, Action<ProblemDetailsOptions>? problemDetails = null)
does the following:
- registers
ZodExceptionHandlerand the sharedZodProblemDetailsWriter; - calls
services.AddProblemDetails(problemDetails)and configuresZodProblemDetailsOptions; - adds the ProblemDetails payload’s source-generated JSON metadata to the minimal-API
JsonOptions, so it serializes under Native AOT; - on
net11.0, enriches the built-in validation pipeline (above).
AddZodSharp also registers the writer and the JSON metadata.
ZodProblemDetailsOptions |
Default | Effect |
|---|---|---|
Registry |
ErrorTypeRegistry.Default |
Resolves error codes to ErrorTypes. |
ErrorTypeResolver |
null |
A lookup used instead of Registry. |
FormatMessages |
true |
Format messages from the matched ErrorType.MessageFormat, only when every placeholder resolves. |
PathNaming |
ZodPathNaming.MemberName |
Json renders keys with the minimal-API JsonOptions naming policy. |
IncludeIssues |
true |
Include the issues extension. |
MergeParametersIntoExtensions |
false |
Also merge every issue’s parameters into the top-level extensions (last wins). |
IncludeDiagnosticParameters |
false |
Also write the diagnostic parameters generated validators attach (declaringType, the full CLR name of the declaring type, and member, the C# member name). They stay on ValidationError.Parameters either way, for localization and logging. |
ProjectDataAnnotationsIssues |
true |
Give non-ZodSharp errors entries an issues entry with code dataannotations (ZodProblemDetailsWriter.DataAnnotationsCode). |
DefaultStatusCode |
400 |
The status when no matched ErrorType supplies one. |
StatusCodeSelector |
null |
Func<ImmutableArray<ValidationError>, int>: takes complete control of the status. It runs after the highest ErrorType.HttpStatus is computed, and its result wins. |
StatusCodeOverride |
null |
Func<ImmutableArray<ValidationError>, int, int>: like StatusCodeSelector, but also receives the computed status. It runs last and wins. |
HandleBindingFailures |
false |
Sets RouteHandlerOptions.ThrowOnBadRequest, so a body rejected during deserialization reaches ZodExceptionHandler in every environment. See Strict deserialization. |
MapErrorType(ErrorType) registers an ErrorType with the configured Registry.
Strict deserialization: value objects rejected while reading
Section titled “Strict deserialization: value objects rejected while reading”A value object read by a strict converter (Purview.ValueObjects’ ValueObjectDeserializationMode.Strict) is
validated while the body is deserialized. A converter cannot know which member of the document it is reading, so it
wraps the ZodException in a JsonException (new JsonException(message, zodException)), and System.Text.Json
records the location in JsonException.Path ($.customerEmail). ZodSharp joins the two: the errors are reported under
the member that held the value, exactly as validating the bound model reports them.
POST /orders{"customerEmail":"not-an-email","quantity":5}{ "status": 400, "errors": { "CustomerEmail.Value": ["Field 'CustomerEmail' must be a valid email address."] }, "issues": [{ "path": "CustomerEmail.Value", "segments": ["CustomerEmail", "Value"], "code": "invalid_string", "origin": "string", ... }]}- Member names. The serializer’s path uses JSON names. The endpoint’s body type (its
IAcceptsMetadata) is resolved from the minimal-APIJsonOptions, and each JSON name is mapped back to its member name through that contract’s metadata - no reflection.PathNamingthen applies as it does everywhere, so the keys match theWithZodSharpValidation()body for the same model under either setting. When the body type cannot be resolved, the JSON names are kept. Collections keep their indexes ($.lines[0].quantity->Lines[0].Quantity.Value). - Display name. An error at the value object’s own member (
Value) is named after the member holding it, as nested validation does:Field 'CustomerEmail' ..., neverField 'Value' .... - A bare
ZodException(older value object releases, or any other caller) is unchanged: its own path. - Any other bad request (malformed JSON, a
JsonExceptionwithout ZodSharp errors) is left as ASP.NET Core answers it, unlessHandleBindingFailuresis set (below).
Whether the failure reaches ZodSharp depends on the framework:
| Development | Production | |
|---|---|---|
| .NET 11 | Rewritten | Rewritten: binding writes its own validation problem through the ProblemDetails service, with the exception attached, and AddZodSharpProblemDetails rewrites it. No opt-in needed. |
| .NET 10 | Rewritten (ThrowOnBadRequest is on in Development) |
A bare 400 with no body, unless HandleBindingFailures = true. |
builder.Services.AddZodSharpProblemDetails(options => options.HandleBindingFailures = true);
var app = builder.Build();app.UseExceptionHandler();HandleBindingFailures sets RouteHandlerOptions.ThrowOnBadRequest, which is application-wide: every
minimal-API binding failure becomes a BadHttpRequestException that travels through the exception handler middleware
instead of a status code set in place. ZodExceptionHandler then answers the ones without ZodSharp errors with their
own status (BadHttpRequestException.StatusCode) and a standard ProblemDetails body that never includes the exception’s message (it can
quote the request). The costs are an exception per bad request and the middleware’s diagnostics, which follow
ExceptionHandlerOptions.SuppressDiagnosticsCallback. An unsupported content type (415) is always answered by
binding itself. MVC (SystemTextJsonInputFormatter) is not covered: it records the failure in ModelState under the
JSON path.
Outside ASP.NET Core, ZodJsonExceptions.TryGetErrors(exception, rootTypeInfo, out errors) (core package) performs
the same re-rooting for any exception chain, and DeserializeAndValidate* / ReadFromJsonAndValidateAsync apply it,
so they return the re-rooted errors rather than invalid_json.
Mapping error types to status codes
Section titled “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:
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<T>("Name") helper instead of an explicit
typeof(...):
Parameters:[ new("AggregateId", typeof(string)), ErrorType.Param<string>("AggregateType")]Both the bundled ZODSASP001 analyzer and the source generator (shipped with the core package)
treat ErrorType.Param<T> 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:
throw new ZodException([ ValidationError.Create( "aggregate_save_failed", "The aggregate could not be saved.", path: [], parameters: new Dictionary<string, object?> { ["AggregateId"] = "agg-123", ["AggregateType"] = "Invoice", }),]);Generated Create / Throw helpers
Section titled “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<T> generic type argument):
// Returns a ValidationError with the code, the formatted message, and the typed parameters.var error = ConcurrentErrorType.CreateSaveFailed("agg-123", "Invoice");
// Throws a ZodException carrying the same ValidationError.ConcurrentErrorType.ThrowSaveFailed("agg-123", "Invoice");
// The path and structured issue metadata can be populated too:var error = ConcurrentErrorType.CreateSaveFailed( "agg-123", "Invoice", path: ["order", "items", "[0]"], origin: "array", minimum: 1, maximum: 10, inclusive: true);The generated Create builds a typed ErrorTypeParameters instance (validated against the declared
parameter types and exposed through ValidationError.Parameters) and sets the message from
ErrorType.FormatMessage, so error.Message already reads
Aggregate 'agg-123' (of type Invoice) failed to save and mapping through the registry produces the
409 Conflict response described below.
ErrorType diagnostics
Section titled “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:
{ "type": "https://tools.ietf.org/html/rfc9110#section-15.5.10", "title": "One or more validation errors occurred.", "status": 409, "detail": "The aggregate could not be saved.", "errors": { "": ["Aggregate 'agg-123' (of type Invoice) failed to save"] }, "issues": [ { "path": "", "segments": [], "code": "aggregate_save_failed", "category": "invalid_value", "message": "Aggregate 'agg-123' (of type Invoice) failed to save", "parameters": { "aggregateId": "agg-123", "aggregateType": "Invoice" } } ], "aggregateId": "agg-123", "aggregateType": "Invoice", "traceId": "..."}Mapping rules:
- Status — the highest matched
ErrorType.HttpStatuswins; unmapped codes fall back toDefaultStatusCode(400).StatusCodeSelectorand thenStatusCodeOverriderun after that computation, and their result wins. - Title / Type / Detail — taken from the highest-status matched
ErrorType; otherwise defaulted.typeis the RFC 9110 URI for the status. - Message — with
FormatMessages, theMessageFormatreplaces the error’s message only when every placeholder resolves, so a template is never returned with{placeholders}left in. Placeholders resolve fromValidationError.Parameters, then from the built-inspath,code,origin,minimum,maximumanddisplayName. The analyzerZODSASP001(shipped with the core package) warns at compile time when aMessageFormatplaceholder is not declared inParameters. - Parameters — the error’s
ValidationError.Parametersare surfaced per issue inissues[].parameters(camel-cased, safe scalars only). WithMergeParametersIntoExtensionsthey are also merged into the top-level extensions, and reserved names are written asparam_{name}.
For on-demand mapping outside a request, pass ZodProblemDetailsOptions with a Registry or an
ErrorTypeResolver lookup to ToHttpValidationProblemDetails / ToValidationProblemDetails; the same mapping
applies to ValidationResult<T>. The 2.x registry and lookup overloads still work but are obsolete (see
The option-less overloads).
Dependency injection
Section titled “Dependency injection”builder.Services.AddZodSharp(options =>{ options.ScanAssemblies.Add(typeof(UserDto).Assembly); options.ScanAssemblyGraphs.Add(typeof(Program).Assembly); options.ScanLoadedAssemblies = true;});AddZodSharp(Action<ZodSchemaFactoryOptions>? configure) registers IZodSchemaFactory as a singleton. When the factory is built, it does three things in order:
- It applies every
ZodSchemaRegistration, including the trim-safe ones added by the generatedservices.AddZodSharp{Assembly}Schemas()extensions. - It applies
options.ConfigureFactory. - It calls
factory.RegisterFromAssembly(assembly)for each distinct assembly discovered from the configured assembly sources, which auto-discovers source-generated[assembly: ZodSchemaGenerated(typeof(...))]registrations.
Assembly scanning is not trim-safe. In a trimmed or Native AOT host, use the generated AddZodSharp{Assembly}Schemas() (see Source Generator).
ZodSchemaFactoryOptions:
List<Assembly> ScanAssemblies— assemblies to scan for generated schemas.List<Assembly> 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<IZodSchemaFactory>? ConfigureFactory— additional factory configuration.
For modular registration, the package also exposes additive assembly-contribution helpers:
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.
Choosing a registration method
Section titled “Choosing a registration method”| Method | Package | Registers | Use when |
|---|---|---|---|
AddZodSharp(options) |
Purview.ZodSharp.AspNetCore |
singleton IZodSchemaFactory + auto-registers generated validators from configured assembly sources |
ASP.NET Core apps that want factory registration plus optional assembly-source configuration. |
AddZodSharpAssembly(...), AddZodSharpAssemblyGraph(...), AddZodSharpLoadedAssemblies() |
Purview.ZodSharp.AspNetCore |
additive generated-validator assembly source contributions | Modular ASP.NET Core apps where deeper layers or implementation packages contribute their own schema assemblies. |
AddZodSharpFactory(configure) |
core Purview.ZodSharp |
singleton IZodSchemaFactory |
Any .NET host where you want manual control — register validators yourself in the configure callback. |
AddZodSharp{Assembly}Schemas() |
generated into each assembly with [ZodSchema] types |
a trim-safe ZodSchemaRegistration naming every generated validator |
Trimmed or Native AOT hosts, and anywhere assembly scanning is unwanted. |
AddZodSharpProblemDetails(...) |
Purview.ZodSharp.AspNetCore |
ZodExceptionHandler, the ProblemDetails writer and services, and the .NET 11 pipeline enrichment — does not register the factory |
Mapping thrown ZodExceptions to ProblemDetails; pair it with AddZodSharp when you also need DI validator resolution. |
AddZodSharpValidation(...) |
Purview.ZodSharp.Extensions.Validation (net11.0) |
the Microsoft.Extensions.Validation resolver | Validating with the .NET 11 pipeline; see Microsoft.Extensions.Validation. |
AddZodSchemaOptionsValidator<T>(...) |
core Purview.ZodSharp |
singleton IValidateOptions<T> |
Validating options objects; requires a factory registered first via AddZodSharp or AddZodSharpFactory. Validates every options name; on .NET 11 ValidateOnStart() runs its asynchronous rules too. |
OptionsBuilder<T>.BindAndValidateWithZod(section) |
Purview.ZodSharp.AspNetCore |
binds a configuration section and adds the ZodSharp options validator | Options whose failures should name the configuration key (Smtp:Servers:0:Host). Uses the reflection-based binder; under Native AOT call BindConfiguration and AddZodSchemaValidator yourself. |
See Dependency Injection for the underlying factory and options-validation wiring.