Purview result types for .NET — a small, dependency-light Result<TValue, TError> type, C# 15 union ergonomics
for its error cases, and integrations that let expected failures flow through a value instead of an exception.
Exceptional circumstances still throw; expected outcomes are values.
| Package | Purpose | Targets |
|---|---|---|
Purview.Results |
Result<TValue, TError> and the Result factories. No dependencies. |
net11.0 |
Purview.Results.SourceGenerator |
Generates AsFailure<TValue>() helpers for [GenerateResult] unions. |
netstandard2.0 |
Purview.Results.ZodSharp |
Bridges ZodSharp ValidationResult<T> values into results. |
net11.0 |
Purview.Results.AspNetCore |
Maps results onto ASP.NET Core responses (IResult, ProblemDetails). |
net11.0 |
Purview.Results.ZodSharp.AspNetCore |
Maps result failures that carry validation errors onto HttpValidationProblemDetails. |
net11.0 |
Each package README is the same file that ships inside the .nupkg (project Sdk/README.md), so this table
links straight to the package documentation. Every package also ships agent skills under .agents/ (union
modelling and the result core, HTTP mapping, and the two ZodSharp bridges), which Purview.BuildSdk mirrors
into a consuming repository's own .agents/ folder on the next restore or build.
using Purview.Results;
Result<Tenant, TenantError> GetTenant(TenantId tenantId) =>
_tenants.TryGet(tenantId, out var tenant)
? Result<Tenant, TenantError>.Success(tenant)
: new TenantNotFound(tenantId).AsFailure<Tenant>();AsFailure<TValue>() is generated by Purview.Results.SourceGenerator for every case of a union opted in with
[GenerateResult]:
[GenerateResult]
public readonly union TenantError(TenantNotFound, TenantDisabled, TenantAlreadyExists);
public readonly record struct TenantNotFound(TenantId TenantId);
public readonly record struct TenantDisabled(TenantId TenantId);
public readonly record struct TenantAlreadyExists(TenantId TenantId);The generator cannot make a bare case value convert implicitly — C# forbids operators in a static class,
conversion operators in extension members, and more than one user-defined conversion per sequence — so the
per-case helper is the ergonomics the language allows. The generator package also ships a code fix for the
IDE and a diagnostic suppressor that answers CA1815 for opted-in unions, so a [GenerateResult] union needs
no #pragma warning disable CA1815, and returning the union itself
((TenantError)new TenantNotFound(id)) is the only helper-free form; see
the generator package README for the compiler evidence.
Expose the result over HTTP with the ASP.NET Core package, which maps each error case to a response:
builder.Services.AddResultsHttp(options => options
.Map<TenantNotFound>(error => TypedResults.NotFound())
.Map<TenantError>(error => TypedResults.Problem(statusCode: StatusCodes.Status409Conflict))
);
app.MapGet("/tenants/{id:int}", (int id) => GetTenant(id)).WithResultsHttp();Every example is a runnable, non-packable project under src/examples, built on the same
Tenancy domain the Quick start uses, so the one TenantError union drives all four integration aspects.
| Example | Packages | Demonstrates |
|---|---|---|
Examples.Basic |
Purview.Results, Purview.Results.SourceGenerator |
States, Match/Map/Bind/MapError/Ensure, probing, the throw-on-misuse contract, and the generated AsFailure<TValue>() helper |
Examples.Zod |
+ Purview.Results.ZodSharp |
A [ZodSchema] input validated into a result, where the rejection carries its ValidationErrors |
Examples.AspNetCore |
+ Purview.Results.AspNetCore |
AddResultsHttp/Map/WithResultsHttp, including the mapping-gap and uninitialized-result paths |
Examples.AspNetCore.Zod |
+ Purview.Results.ZodSharp.AspNetCore |
A validation-carrying failure rendered as HttpValidationProblemDetails, with a case mapping winning over the fallback |
dotnet run --project src/examples/Examples.Basic
dotnet run --project src/examples/Examples.Zod
dotnet run --project src/examples/Examples.AspNetCore --urls http://localhost:5215
dotnet run --project src/examples/Examples.AspNetCore.Zod --urls http://localhost:5216Result<Tenant, TenantError> holds one of three states — Uninitialized (the default value), Success or
Failure — and every expected outcome is read from the value rather than caught:
Result<Tenant, TenantError> GetTenant(TenantId tenantId) =>
_tenants.TryGetValue(tenantId, out var tenant)
? Result<Tenant, TenantError>.Success(tenant)
: new TenantNotFound(tenantId).AsFailure<Tenant>();
var loaded = GetTenant(tenantId);
loaded.Match(tenant => $"loaded '{tenant.Name}'", error => $"could not load the tenant: {error}");
loaded.Map(tenant => tenant.Name);
loaded.Bind(tenant => store.CreateTenant(new TenantId("newco"), tenant.Name));
loaded.Ensure(tenant => tenant.Enabled, tenant => new TenantDisabled(tenant.Id));
loaded.TryGetError(out var error); // probing never throws, even for `default`
loaded.Value; // throws InvalidOperationException unless the result is a successZodSharp validation never throws: Validate returns a ValidationResult<TenantInput> carrying the validated
value on success and every ValidationError on failure. ToResult turns that into an ordinary result whose
error is one of the union's cases:
[ZodSchema]
public sealed partial record TenantInput
{
[Required]
public string? TenantId { get; init; }
[Required]
public string? Name { get; init; }
}
Result<Tenant, TenantError> Register(TenantInput input) =>
TenantInputSchema
.Validate(input)
.ToResult<TenantInput, TenantError>(errors => new TenantInputInvalid(input, errors))
.Bind(RegisterValidated);When the value the method succeeds with is not the validated value, the generated helper produces the failure instead, because the validated value cannot be carried forward:
var validation = TenantInputSchema.Validate(input);
if (!validation.IsSuccess)
return new TenantInputInvalid(input, validation.Errors).AsFailure<Tenant>();
return RegisterValidated(validation.Value);The host decides what each error case looks like on the wire. A mapping registered for the case type wins; the mapping registered for the error type covers every case without one:
builder.Services.AddResultsHttp(options => options
.Map<TenantNotFound>(error => TypedResults.NotFound(new { error = nameof(TenantNotFound), tenantId = error.TenantId.Value }))
.Map<TenantDisabled>(error => TypedResults.Problem(statusCode: StatusCodes.Status403Forbidden, title: "The tenant is disabled."))
.Map<TenantError>(_ => TypedResults.Problem(statusCode: StatusCodes.Status409Conflict, title: "The tenant already exists."))
);
app.MapGet("/tenants/{id}", (string id) => store.GetTenant(new TenantId(id))).WithResultsHttp();Running Examples.AspNetCore answers as follows:
| Request | Response |
|---|---|
GET /tenants/acme |
200 OK with the tenant |
GET /tenants/initech |
404 Not Found — the mapping for the TenantNotFound case |
GET /tenants/globex/usage |
403 Forbidden — the mapping for the TenantDisabled case |
POST /tenants/acme |
409 Conflict — the mapping for the TenantError error type |
GET /tenants/broken |
500 with an errorType extension, because an endpoint returning default is a host bug |
Case and error mappings are keyed by type. When the answer depends on the value a failure carries — a
validation code, a category, a field — add an IResultsFailureMapper instead, in the same ordered list:
builder.Services.AddSingleton<ReservedTenantFailureMapper>();
builder.Services.AddResultsHttp(options => options
.Map<TenantNotFound>(_ => TypedResults.NotFound())
.AddFailureMapper<ReservedTenantFailureMapper>());Register the validation mapping last, so any mapping or failure mapper the host declared earlier always wins, and reuse the ZodSharp problem mapper rather than reimplementing error-to-problem mapping. Per-code and per-category rules answer particular validation failures with a response of their own:
builder.Services.AddZodSharpProblemDetails();
builder.Services.AddResultsHttp(options => options
.Map<TenantAlreadyExists>(error => TypedResults.Problem(statusCode: StatusCodes.Status409Conflict))
);
builder.Services.AddResultsZodSharpHttp(options => options
.MapCode("tenant_id_matches_name", StatusCodes.Status422UnprocessableEntity)
);A TenantInputInvalid failure implements IValidationErrorCarrier, so it becomes a validation problem without
the host mapping it — unless a rule answers one of its codes or categories:
{
"title": "One or more validation errors occurred.",
"status": 400,
"errors": { "TenantId": ["Required field 'TenantId' is null"] },
"issues": [{ "code": "missing_field", "path": ["TenantId"], "message": "Required field 'TenantId' is null" }],
"traceId": "0HNOUQVQNF7CV:00000001"
}The full documentation suite lives in docs/wiki:
- Getting started
- Core concepts and combinators
- Union errors
- Source generator and diagnostics
- ASP.NET Core integration
- ZodSharp integration and problem details
- Guarantees and limitations
- .NET 11 SDK or later — the runtime packages target
net11.0; the source generator targetsnetstandard2.0so any compiler host can load it. - C# 15 preview — union declarations are a preview language feature, so consuming projects need an SDK with
union support and
LangVersion=preview(the repository sets both).
| Path | Purpose |
|---|---|
src/Results.slnx |
Canonical solution for restore, build, test and pack |
src/src/Results |
Result<TValue, TError>, Result factories, IResultValue |
src/src/SourceGenerator |
Roslyn incremental generator + diagnostic analyzer for [GenerateResult] |
src/src/AspNetCore |
Result-to-response mapping, endpoint filter and DI registration |
src/src/ZodSharp |
ZodSharp ValidationResult<T> bridge |
src/src/ZodSharp.AspNetCore |
HttpValidationProblemDetails mapping for validation-carrying failures |
src/src/<Project>/Sdk |
Package-only assets: README.md (packed as the package README) and any Sdk/.agents/** skills |
src/tests |
TUnit unit tests, including source-generation and incremental-cache tests |
src/examples |
Runnable, non-packable examples: one project per integration aspect, built on the Tenant* domain |
docs/wiki |
User-facing documentation suite, aggregated by the purview-dev website |
Directory.Packages.props |
Centrally managed NuGet versions |
src/Directory.Build.props / src/Directory.Build.targets |
Solution-wide SDK, package and build behaviour |
global.json |
Required .NET SDK, Purview.BuildSdk and Microsoft.Testing.Platform selection |
package.json |
Authoritative repository/package version |
Justfile |
Supported local workflow commands |
AGENTS.md |
Repository instructions for AI agents |
dotnet tool restore
just build # build the solution (Debug)
just test-unit # run the unit tests
just lint-check # CSharpier + .slnx GUID validation
just pack # pack all five packages to ./artifacts
just pipeline-pr # the full PR pipeline (restore, build, lint, test, pack)just pipeline-pr installs the pinned Purview.Build tool to .tools/purview-build when it is missing.
Local CI-equivalent validation:
dotnet restore src/Results.slnx
dotnet build src/Results.slnx --no-restore
dotnet test src/Results.slnx --no-build --treenode-filter "/*/*/*/*[Category=Unit]"
dotnet csharpier check .package.json is the authoritative version; every package is versioned from it by Purview.BuildSdk. Pushing
to main runs the shared Purview.Build release pipeline, which packs, publishes to NuGet and creates the
v<version> GitHub release when that tag does not already exist. See
.github/workflows/release.yml.
MIT — see LICENSE.md.