Skip to content
Purview Dev logo

Unions

Preview

Dependency-free C# 15 unions that do not box value-type cases.

boxing c-sharp code-generation csharp csharp-15 discriminated-unions dotnet net10 net11 nuget performance roslyn source-generator union union-types unions value-types zero-allocation

The problem

The C# 15 union keyword lowers to a single object? field, so every value-type case is boxed on entry. Purview.Unions generates the language's non-boxing custom-union shape instead: each case gets its own typed field, a public constructor, and a TryGetValue that never allocates. The compiler still sees a union — implicit conversions, exhaustive switch, and is/null patterns all work — plus a hand-written Union<T1, T2> for the two-case shape.

From the repository

Dependency-free C# 15 unions that do not box value-type cases - a source generator emits the non-boxing custom-union shape with typed storage, TryGetValue, exhaustive switch and UNI diagnostics, plus a hand-written Union<T1, T2>.

Why it became a project

Discriminated unions were needed for error modelling and optional values, and the language's own union declaration boxes every value-type case on entry. The compiler already exposes the custom-union contract the boxing-free shape needs, so the generator that emits it became a shared package instead of a hand-written type in each project.

Where it fits

Use it when

You model a closed set of cases — expected errors, optional results, a small state machine — and either the value-type cases are allocation-sensitive or you want one type the whole team matches on exhaustively.

It may not fit when

You target a runtime below .NET 11 with C# 15, or a single shape with two reference-type cases is enough: the built-in union declaration is simpler and the boxing cost does not apply to reference types.

Use cases

Concrete examples of what Unions does, and who each one is for.

Developer

Match a value-type union without boxing a case

A method returns one of a handful of value-type outcomes, and the caller must branch on which one it received — but the union keyword boxes every value-type case on entry and again while matching.

csharp
[GenerateUnion(typeof(int), typeof(string))]
public readonly partial struct IntOrString;

IntOrString value = 42;

var message = value switch
{
    int number => $"int {number}",
    string text => $"string {text}",
    null => "empty",
};

What you get: The compiler treats the generated type as a union, so implicit conversions, exhaustive switch and is/null patterns all work, while construction, TryGetValue, is and switch never allocate.

The committed BenchmarkDotNet suite measures construction, both TryGetValue directions, the miss path, HasValue, is and both switch shapes at 0 B; only reading Value allocates the 24-byte box for the int case, which is inherent to the union contract.

Read the guide →
Developer

Declare a union whose cases are type parameters

You want a generic option or outcome type, but typeof(T) is not a legal attribute argument, so an ordinary [GenerateUnion(typeof(…))] list cannot name a type parameter.

csharp
[GenerateUnion]
public readonly partial struct Option<T>
{
    internal static partial Option<T> Some(Some<T> value);
    internal static partial Option<T> None(None value);
}

What you get: Each partial factory signature names a case, the generator supplies the bodies and derives the public constructors from them, and the compiler reports CS0416 only if you try to use a typeof(T) list instead.

A generic union must name its cases in source because CS0416 rejects typeof(T) in an attribute; a typeof list on a generic union is reported as UNI0011.

Read the guide →
Architect

Let a second source generator see the union's cases

Another Purview generator — Results, or the telemetry generator unwrapping a parameter — must read the case list, but Roslyn generators never see each other's generated output.

csharp
[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);

        bool TryGetValue(out T value);
        bool TryGetValue(out Exception value);
    }
}

What you get: A public nested IUnionMembers provider exposes the case list in the source the other generator compiles against, while this generator still emits the storage and CreateCaseN methods.

Purview.Telemetry.SourceGenerator 6.0.0 reads the source-visible case list in the same order as this package, and a [GenerateUnion] target with no source-visible case list is reported as UNI0003 rather than unwrapped.

Read the guide →
Contributor

Fail fast on a malformed union declaration

A union is declared without partial, with a duplicate case, or with a case type less accessible than the union, and the generated shape cannot be emitted coherently.

plaintext
error UNI0001: The union declaration is not partial
error UNI0004: Duplicate case type
error UNI0005: A case type is less accessible than the union

What you get: Every error blocks its union's output rather than emitting a partial shape, and the package ships code fixes for the common declarations — add partial (UNI0001), widen an inaccessible case type (UNI0005), add [Union] on the fallback path (UNI0007).

UNI0015 (a single-case union) is the only warning; every other UNI rule blocks emission, so a malformed union fails the build instead of producing half a type.

Read the guide →

Packages

1 package

Purview.UnionsnugetInstall ↓—1.0.0-prerelease.3—

Install

.NET CLI
dotnet add package Purview.Unions

Agent skills

Unions authors agent skills for coding agents. They are installed explicitly with the skills CLI and are not part of any package.

Skills are installed explicitly with the skills CLI. Nothing ships them inside a NuGet package, and no build writes into your agent's directories: installation is a decision you make. Use whichever package manager you already have.

npm

Terminal window
npx skills add purview-dev/unions

bun

Terminal window
bunx skills add purview-dev/unions

pnpm

Terminal window
pnpm dlx skills add purview-dev/unions

yarn

Terminal window
yarn dlx skills add purview-dev/unions
  • Installs at project level by default. Add -g for a user-level install, or -a <agent> to target one agent.
  • To match the release you use, pin a commit or tag by appending #<ref> to any command above (for example npx skills add purview-dev/unions#<ref>).
  • The CLI records what it installed in skills-lock.json; restore it with the same runner:
Terminal window
npx skills experimental_install
  • purview-unions-diagnostics

    Use when a Purview.Unions build reports a UNI diagnostic (UNI0001 to UNI0016 or UNI9000), when a generated union is missing its members, when a union conversion or exhaustive switch does not compile, or when deciding whether a finding is a design error or a code fix.

    1 file
  • purview-unions-interop

    Use when a Purview.Unions union meets another library — Purview.Results error unions, Purview.Telemetry parameters, ZodSharp, JSON serialisation, trimming and Native AOT — or when deciding which downstream generators can see a generated union's cases.

    1 file
  • purview-unions-modelling

    Use when modelling a closed set of cases as a C# 15 union with Purview.Unions — choosing between the typeof list, partial factory signatures and an IUnionMembers provider, writing class or struct unions (including generics), or explaining why Value boxes but pattern matching does not.

    1 file
  • purview-unions-quickstart

    Use when adding Purview.Unions to a .NET project for the first time — installing the package, compiling with C# 15, declaring a first [GenerateUnion] union, matching it with an exhaustive switch, or falling back to the hand-written Union<T1, T2> without a generator.

    1 file