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 its value-less counterpart Result<TError>, the Result factories, and the bundled source generator that emits per-case AsFailure<TValue>()/AsFailure() helpers and union-receiver Union.Failure(...)/Union.Success(...) factories for [GenerateResult] unions. No runtime dependencies. |
net11.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. The source generator is not a separate package: it ships inside
Purview.Results under analyzers/dotnet/cs, together with its IDE code fix. 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 the source generator bundled in Purview.Results 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 also declares a C# 14 extension block on the union itself, so the union names the error and the
call site stays unambiguous: TenantError.Failure(tenantNotFound) produces Result<TenantError>,
TenantError.Failure<Tenant>(tenantNotFound) produces Result<Tenant, TenantError>, and
TenantError.Success() / TenantError.Success(tenant) produce the success shapes. This union-receiver factory is
the shared-case safe form: when a leaf case type belongs to more than one union, the per-case AsFailure
helper is generated once and reported as RSG1006, so the factory is the call that always binds to the union you
name. A case type that is itself a union is an included union: the factory also covers its cases and builds the
nested value, so composition replaces leaf sharing and no RSG1006 is raised for it.
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. Purview.Results 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 source generator 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/src/Examples.*, built on the same
Tenancy domain the Quick start uses, so one error-union vocabulary drives every integration aspect.
| Example | Packages | Demonstrates |
|---|---|---|
Examples.Basic |
Purview.Results |
States, Match/Map/Bind/MapError/Ensure, probing, the throw-on-misuse contract, the generated AsFailure<TValue>() and AsFailure() helpers and the union-receiver Union.Failure(...)/Union.Success(...) factories, cross-service error-union widening, value-less Result<TError> commands, and Throw() |
Examples.Zod |
+ Purview.Results.ZodSharp |
A [ZodSchema] input validated into a result, where the rejection carries its ValidationErrors, and a value-discarding ToUnitResult validation |
Examples.AspNetCore |
+ Purview.Results.AspNetCore |
AddResultsHttp/Map/WithResultsHttp, nested error-union leaf mapping, a successful unit result answering 204, 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 |
Examples.ValueObjects.Zod |
Purview.Results.ZodSharp, Purview.ValueObjects |
A [Scalar] value object whose type-level [ZodRule] owns its code and origin, validated into a result failure the HTTP layer can answer by origin |
dotnet run --project src/src/Examples.Basic
dotnet run --project src/src/Examples.Zod
dotnet run --project src/src/Examples.AspNetCore --urls http://localhost:5215
dotnet run --project src/src/Examples.AspNetCore.Zod --urls http://localhost:5216
dotnet run --project src/src/Examples.ValueObjects.ZodResult<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)
: TenantError.Failure<Tenant>(new TenantNotFound(tenantId));
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 successWhen one service calls another, the caller's operation-family union includes the callee's error union as a case,
and the widening Bind (or a TryGetError guard with the generated helper) lifts the callee's failure into it.
Throw() is the escape hatch for a boundary that must throw:
[GenerateResult]
public readonly union RegisterTenantError(TenantError, BillingError);
Result<Tenant, RegisterTenantError> Register(TenantId id, string name) =>
billing.ReserveQuota(id) // Result<Quota, BillingError>
.Bind(quota => CreateTenant(quota, name), error => error);
store.GetTenant(id).Throw(); // returns the value, or throws ResultException<TenantError>When an operation has nothing to return on success, use the value-less Result<TError>. It carries the same
three states and the same throw-on-misuse contract, but succeeds with the Success marker instead of a value,
and the generator emits a non-generic AsFailure() for the same cases:
Result<TenantError> DeleteTenant(TenantId tenantId) =>
_tenants.Remove(tenantId)
? Result<TenantError>.Success()
: new TenantNotFound(tenantId).AsFailure();
var deleted = DeleteTenant(tenantId);
deleted.Match(() => "deleted", error => $"could not delete: {error}"); // the success arm takes no value
deleted.Map(() => 1); // attach a value to a success
Result<Tenant, TenantError> loaded = deleted.Bind(() => GetTenant(tenantId));
loaded.DiscardValue(); // and back to a unit resultZodSharp 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 most specific case type wins, then a mapping for an enclosing union type, then the mapping registered for the error type, which covers every case without one. A nested union resolves to its leaf:
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 |
DELETE /tenants/hooli |
204 No Content — a successful unit Result<TenantError> carries no payload |
DELETE /tenants/initech |
404 Not Found — the unit result's TenantNotFound failure maps by case |
GET /tenants/acme/billing |
503 Service Unavailable — the leaf of the nested TenantOperationError union |
GET /tenants/hooli/billing |
402 Payment Required — another leaf of the nested union |
GET /tenants/initech/billing |
404 Not Found — a nested tenant failure resolves to its leaf |
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, per-category and per-origin 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)
.MapOrigin("value_object", 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, categories or origins:
{
"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
- Value objects composition
- Guarantees and limitations
-
.NET 10 or .NET 11 — the runtime packages multi-target
net10.0andnet11.0, so adopting them does not force a runtime upgrade. The source generator bundled inPurview.Resultstargetsnetstandard2.0so any compiler host can load it. .NET 8 and 9 are not targeted; both are close to end of support. -
Unions require .NET 11. The compiler needs
System.Runtime.CompilerServices.IUnionandUnionAttribute, which do not exist earlier, so a[GenerateResult]union declaration cannot compile fornet10.0. Everything else works on both: the result types, the combinators, the ASP.NET Core mapping and the ZodSharp bridge. Onnet10.0an error type is simply a plain type rather than a union, which the HTTP mapper already handles — register a mapping for the error type itself. -
C# 15 preview — only to declare a union. The requirement is per-project and scoped:
- Using
Result<TValue, TError>,Result<TError>and the combinators needs nothing special. NoLangVersion, noEnablePreviewFeatures. - Declaring your own
[GenerateResult]union needsLangVersion=previewin that project, because the union syntax is yours to compile.
These packages deliberately do not set
EnablePreviewFeatures. Doing so emits[assembly: RequiresPreviewFeatures], which makes CA2252 (an error by default) fire on every member a consumer touches — assembly-wide, so it applied even to the non-union result type and made partial adoption impossible. Nothing here uses a runtime API annotated[RequiresPreviewFeatures], so the attribute was removed. If you see CA2252 from these packages, that is a bug — please report it. - Using
| Path | Purpose |
|---|---|
src/Results.slnx |
Canonical solution for restore, build, test and pack |
src/src/Results |
Result<TValue, TError> and Result<TError>, Success, the 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/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 four 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.