Union Errors
Union Errors
Section titled “Union Errors”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.
Declaring the union
Section titled “Declaring the union”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.
The generated helper
Section titled “The generated helper”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.
Why there is no implicit conversion
Section titled “Why there is no implicit conversion”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:
| Rule | Diagnostic |
|---|---|
| A user-defined operator cannot be declared in a static class — the generated helper class is one | CS0715 |
| A conversion operator must be declared by the source or the target type; a helper class is neither | CS0556 |
| Conversion operators are not permitted as extension members | CS9282 |
A conversion operator cannot declare its own type parameters, so TValue is out of scope for a non-generic case type | CS0246 |
Only one user-defined conversion may participate in a sequence, so case → union → Result<…> can never compose | CS0029 |
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.
The helper-free form
Section titled “The helper-free form”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.
The code fix
Section titled “The code fix”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); // CS0029return new TenantNotFound(id).AsFailure<Tenant>(); // after the fixA 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.
Union shapes that are supported
Section titled “Union shapes that are supported”| Shape | Supported |
|---|---|
| A union declaration whose cases are public single-parameter constructors | Yes |
A type marked [Union] with public single-parameter constructors | Yes |
| A case type shared by two unions | Yes — RSG1006 warning; the helper is generated once |
| A generic union | No — RSG1002 |
| A case type that itself contains type parameters | No — RSG1002 (the remaining cases still generate) |
IUnionMembers member providers | No — RSG1007 |
See Diagnostics for the full rules table and the blocking policy.
Related
Section titled “Related”- Source Generator — activation, pipeline design and build properties.
- Diagnostics — every rule and the
CA1815suppression. - ASP.NET Core Integration — mapping each case onto a response.