Dependency-free C# 15 unions that do not box value-type cases.
The union keyword always stores its content in a single object?, so every value-type case is boxed on
entry. Purview.Unions generates the language's custom union type shape instead: each case gets its own
typed field, a public constructor, and a non-boxing TryGetValue. The compiler still treats it as a union —
implicit conversions, exhaustive switch, and is/null patterns all work — but construction and pattern
matching never allocate.
using Purview.Unions;
[GenerateUnion(typeof(int), typeof(string))]
public readonly partial struct IntOrString;
IntOrString value = 42;
Console.WriteLine(value switch
{
int number => $"int {number}",
string text => $"string {text}",
null => "empty",
});| Package | Purpose | Targets |
|---|---|---|
Purview.Unions |
The [GenerateUnion] attribute, the source generator and code fixes, and the hand-written Union<T1, T2> |
net10.0, net11.0 |
dotnet add package Purview.UnionsThe package README is the same file that ships inside the .nupkg
(src/src/Unions/Sdk/README.md). The package carries the merged generator and its code fixes under
analyzers/dotnet/cs, so one PackageReference is all a consumer needs.
[GenerateUnion(typeof(int), typeof(string))]
public readonly partial struct IntOrString;The generator emits the constructors, storage and access members, and the
[System.Runtime.CompilerServices.Union] attribute the compiler recognises.
[GenerateUnion]
public readonly partial struct Option<T>
{
internal static partial Option<T> Some(Some<T> value);
internal static partial Option<T> None(None value);
}The case type is each factory's single parameter. Use this when a case names a type parameter, because
typeof(T) is not a legal attribute argument (CS0416). The generator supplies the bodies and derives the
public constructors from them.
[GenerateUnion]
public readonly partial struct Outcome<T> : Outcome<T>.IUnionMembers
{
public interface IUnionMembers
{
static Outcome<T> Create(T? value) => Outcome<T>.CreateCase0(value);
static Outcome<T> Create(Exception? error) => Outcome<T>.CreateCase1(error);
object? Value { get; }
bool HasValue { get; }
bool TryGetValue(out T value);
bool TryGetValue(out Exception value);
}
}When a union directly contains a public nested interface named IUnionMembers, the language finds every
union member there instead of on the union. The generator emits the storage and CreateCaseN methods; the
provider owns its one-line Create bodies, because a source generator cannot add bodies to an interface you
declared.
The provider must be public, so a strict analyzer set reports CA1034 (nested types should not be
visible) and CA1515 (public types should be internal). A generic union's provider is itself generic, so its
static Create members are reported as CA1000 (do not declare static members on generic types). The
package's UNI0000, UNI9001 and UNI9002 suppression descriptors handle all three for an IUnionMembers
provider nested in a union; no attribute is needed. The nested shape is mandated by the language, not a
design choice.
Purview.Unions.Union<int, string> value = 42;
if (value.TryGetValue(out int number))
Console.WriteLine(number); // 42No generator, no attributes. On net10.0 it is an ordinary struct with the same API; on net11.0 it also
carries [Union], so it is a compiler union there too.
Every union satisfies the language contract with public object? Value { get; }, and reading Value
boxes a value-type case. That is inherent to the contract and is documented rather than hidden.
What the compiler uses for patterns is the non-boxing access pattern the generator emits:
| Operation | Allocates? |
|---|---|
value = 42 (union conversion) |
No |
value.TryGetValue(out int number) |
No |
value is int |
No |
value switch { int n => …, string s => … } |
No |
value is null (HasValue) |
No |
value.Value |
Yes, for a value-type case |
src/tests/SourceGenerator.UnitTests/GeneratedUnionRuntimeTests.cs asserts exactly this with
GC.GetAllocatedBytesForCurrentThread().
The committed BenchmarkDotNet suite in src/src/Benchmarks measures the same operations, the generic surfaces
and the generator itself, with an object-backed baseline. Run just perf-tests (Release, net10.0) or
just perf-tests-net11; the recorded results are on the Performance page.
struct, readonly struct, class, record struct and record class are all supported. A class union uses
a null reference as the empty union; a struct union uses default.
A nullable value-type case is normalised to its underlying type: int? becomes case int, the constructor
takes int?, and TryGetValue(out int) returns false for null — a null value leaves the union empty.
- A C# 15 compiler: the .NET 11 SDK (or later). C# 15 is GA, so
LangVersion=previewis not required. - Target
net10.0ornet11.0. Anet11.0project gets C# 15 by default, so it needs no language-version switch. Anet10.0project defaults to C# 14, so set<LangVersion>latest</LangVersion>in the project that declares a union. Onnet10.0the generator also emits internal[Embedded]polyfills forUnionAttribute/IUnion; onnet11.0the BCL types are used. - Do not set
EnablePreviewFeatures. It emitsRequiresPreviewFeaturesassembly-wide and makesCA2252fire on every member a consumer touches.LangVersion=latestis the only switch needed, and only onnet10.0.
The generated union is CA1815-clean: Purview.BuildSdk's PDS0006 suppresses the warning for a union
declaration, because a union is matched by its active case rather than compared by value. Surface C is
CA1034-, CA1515- and CA1000-clean: the package's UNI0000, UNI9001 and UNI9002 suppression
descriptors cover the language-mandated public nested IUnionMembers provider and its static Create
members. No #pragma is needed.
| ID | Severity | Meaning |
|---|---|---|
UNI0001 |
Error | The union declaration is not partial |
UNI0002 |
Error | Unsupported declaration kind |
UNI0003 |
Error | The union declares no cases |
UNI0004 |
Error | Duplicate case type |
UNI0005 |
Error | A case type is less accessible than the union |
UNI0006 |
Error | More cases than the tag can address |
UNI0007 |
Error | [Union] is required but missing (fallback path) |
UNI0008 |
Error | Two case types are implicitly convertible |
UNI0009 |
Error | A case type cannot be a union case |
UNI0010 |
Error | A nullable value-type case has no usable underlying form |
UNI0011 |
Error | A generic union has no source-visible case list |
UNI0012 |
Error | Existing constructors and the declared case list disagree |
UNI0013 |
Error | The union is nested in a type the generator cannot name |
UNI0014 |
Error | More than one case list is present and they conflict |
UNI0015 |
Warning | A single-case union is degenerate |
UNI0016 |
Error | The declaration collides with a required member |
UNI9000 |
Error | The generator failed unexpectedly |
The package also ships code fixes that add the missing partial modifier (UNI0001), make an inaccessible
case type public (UNI0005) and, on the documented fallback path, add the [Union] attribute (UNI0007).
- Getting Started
- Union Design — the compiler experiments and the polyfill decision
- Diagnostics
- Interop — Results, Telemetry, ZodSharp and the follow-ups
- Trimming and AOT
- Performance — the benchmark suite and the measured allocation results
- Agent Skills — what each published skill covers and when to load it
- Release Flow — the PR and release gates
The purview-unions-quickstart, purview-unions-modelling, purview-unions-diagnostics and
purview-unions-interop skills cover this package. Install them from the repository:
npx skills add purview-dev/unionssrc/src/Examples is a runnable console sample of all four surfaces:
dotnet run --project src/src/ExamplesMIT — see LICENSE.md.