Skip to content

Union Errors

Preview Results Reviewed 2026-09-30 purview-dev/results Star on GitHub 1 aspnetcore c-sharp csharp discriminated-unions dotnet error-handling minimal-apis nuget problem-details result-type roslyn source-generator union-types zodsharp

A C# 15 union is a natural TError: the error cases stay strongly typed, and a caller still matches on one value. This page covers the modelling rules and the helper the source generator supplies so a case value can be returned where a result is expected.

using Purview.Results;
[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);

[GenerateResult] is generated by Purview.Results.SourceGenerator during post-initialization, so the consuming project does not declare it and needs no second package reference. Opt-in is explicit: a compilation that never applies the attribute gets no helpers.

For each case type the generator emits one overload in a {Union}ResultExtensions static class in the union’s own namespace:

public static class TenantErrorResultExtensions
{
public static Result<TValue, TenantError> AsFailure<TValue>(this TenantNotFound error) =>
Result<TValue, TenantError>.Failure(error);
public static Result<TValue, TenantError> AsFailure<TValue>(this TenantDisabled error) =>
Result<TValue, TenantError>.Failure(error);
public static Result<TValue, TenantError> AsFailure<TValue>(this TenantAlreadyExists error) =>
Result<TValue, TenantError>.Failure(error);
}
Result<Tenant, TenantError> GetTenant(TenantId tenantId) =>
_tenants.TryGetValue(tenantId, out var tenant)
? Result<Tenant, TenantError>.Success(tenant)
: new TenantNotFound(tenantId).AsFailure<Tenant>();

One overload is generated per case type rather than one for the union, because C# does not apply union conversions to an extension-method receiver (CS1929), so a helper declared on the union itself would never bind.

The shortest possible call site would be return new TenantNotFound(id);. C# blocks every route to a generated implicit conversion, and each rule is recorded as a compiler experiment in src/tests/SourceGenerator.UnitTests/UnionCompilerBehaviourTests.cs:

RuleDiagnostic
A user-defined operator cannot be declared in a static class — the generated helper class is oneCS0715
A conversion operator must be declared by the source or the target type; a helper class is neitherCS0556
Conversion operators are not permitted as extension membersCS9282
A conversion operator cannot declare its own type parameters, so TValue is out of scope for a non-generic case typeCS0246
Only one user-defined conversion may participate in a sequence, so case → union → Result<…> can never composeCS0029

The one shape the language accepts is a generic case type carrying the value type parameter — precisely the shape the generator rejects as RSG1002. The per-case helper is therefore the best ergonomics the language allows.

A cast closes the case → union conversion so only the library’s union → result conversion remains, which means the cast form needs no generated code:

Result<Tenant, TenantError> GetTenant(TenantId tenantId) =>
(TenantError)new TenantNotFound(tenantId);

It is not prettier than AsFailure<Tenant>(), but it is a legitimate choice when a project does not want the generator applied to a union.

Returning a bare case value where a result is expected is a compiler error (CS0029). The package ships UnionCaseResultCodeFixProvider, which offers the rewrite in the IDE from the lightbulb:

return new TenantNotFound(id); // CS0029
return new TenantNotFound(id).AsFailure<Tenant>(); // after the fix

A fix is offered only when the rewrite will bind: the converted type is Purview.Results.Result<TValue, TError>, TError is a union and opted in with [GenerateResult], the expression’s type is one of that union’s case types, and the union is reachable by its simple name at the call site.

ShapeSupported
A union declaration whose cases are public single-parameter constructorsYes
A type marked [Union] with public single-parameter constructorsYes
A case type shared by two unionsYes — RSG1006 warning; the helper is generated once
A generic unionNo — RSG1002
A case type that itself contains type parametersNo — RSG1002 (the remaining cases still generate)
IUnionMembers member providersNo — RSG1007

See Diagnostics for the full rules table and the blocking policy.