Skip to content
purview-devPublic

About

Result types for .NET - a small, dependency-light Result<TValue, TError> where expected failures are values instead of exceptions, C# 15 union error cases with generated AsFailure<TValue>() helpers, and ASP.NET Core and ZodSharp integrations that map failures to HTTP responses.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

Purview Results

NuGet version Release

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.

Packages

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.

Quick start

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();

Examples

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.Zod

Basic

Result<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 success

When 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 result

Zod

ZodSharp 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);

ASP.NET Core

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>());

ASP.NET Core + Zod

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"
}

Documentation

The full documentation suite lives in docs/wiki:

Requirements

  • .NET 10 or .NET 11 — the runtime packages multi-target net10.0 and net11.0, so adopting them does not force a runtime upgrade. The source generator bundled in Purview.Results targets netstandard2.0 so 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.IUnion and UnionAttribute, which do not exist earlier, so a [GenerateResult] union declaration cannot compile for net10.0. Everything else works on both: the result types, the combinators, the ASP.NET Core mapping and the ZodSharp bridge. On net10.0 an 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. No LangVersion, no EnablePreviewFeatures.
    • Declaring your own [GenerateResult] union needs LangVersion=preview in 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.

Repository layout

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

Building and testing

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 .

Versioning and releases

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.

License

MIT — see LICENSE.md.

About

Result types for .NET - a small, dependency-light Result<TValue, TError> where expected failures are values instead of exceptions, C# 15 union error cases with generated AsFailure<TValue>() helpers, and ASP.NET Core and ZodSharp integrations that map failures to HTTP responses.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages