# Unions > Dependency-free C# 15 unions that do not box value-type cases. 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` for the two-case shape. ## Documentation version - Channel: live (default) - Source: purview-dev/unions@main ## Resources - Repository: https://github.com/purview-dev/unions - NuGet package: https://www.nuget.org/packages/Purview.Unions - Project page: https://purview.dev/projects/unions/ - Documentation: https://purview.dev/docs/unions/ - Full machine-readable content: https://purview.dev/projects/unions/llms-full.txt - Concise machine-readable index: https://purview.dev/projects/unions/llms.txt ## Install ```bash dotnet add package Purview.Unions ``` `Purview.Unions` targets `net10.0` and `net11.0` and carries the source generator and its code fixes under `analyzers/dotnet/cs`. One `PackageReference` is enough. ## Declare a union ```csharp using Purview.Unions; namespace Tenancy; public readonly record struct TenantNotFound(TenantId TenantId); public readonly record struct TenantDisabled(TenantId TenantId); [GenerateUnion(typeof(TenantNotFound), typeof(TenantDisabled))] public readonly partial struct TenantError; ``` The declaration must be `partial` and a `struct`, `readonly struct`, `class` or record. The generator adds: - one public constructor per case, - a typed field per case and a tag, - `public object? Value`, - `public bool HasValue`, - `public bool TryGetValue(out TCase value)` per case, - the `[System.Runtime.CompilerServices.Union]` attribute. ## Use it ```csharp TenantError error = new TenantNotFound(tenantId); // implicit union conversion var message = error switch { TenantNotFound notFound => $"tenant {notFound.TenantId} was not found", TenantDisabled disabled => $"tenant {disabled.TenantId} is disabled", null => "no error", }; ``` `switch` is exhaustive over the case types, and the `null` arm handles an empty (default) struct union. The compiler uses the generated `TryGetValue`/`HasValue` for those patterns, so no case is boxed. ## Generic unions `typeof(T)` is `CS0416`, so a generic union names its cases in source: ```csharp public readonly record struct Some(T Value); public readonly record struct None; [GenerateUnion] public readonly partial struct Option { internal static partial Option Some(Some value); internal static partial Option None(None value); } Option some = new Some(42); ``` A nested `IUnionMembers` provider is the alternative when another generator must see the case list; the provider is public, so the package's `UNI0000`, `UNI9001` and `UNI9002` suppressors handle the resulting `CA1034`, `CA1515` and `CA1000`. See [Union Design](union-design/). ## No generator ```csharp Purview.Unions.Union value = 42; if (value.TryGetValue(out int number)) { } ``` `Union` is available on every target framework and is also a compiler union on `net11.0`. ## Requirements - The .NET 11 SDK (C# 15, GA). - Target `net10.0` or `net11.0`. - A `net11.0` project gets C# 15 by default, so no `LangVersion` is needed. A `net10.0` project defaults to C# 14, so set `latest` in the project that declares a union. - Do not set `EnablePreviewFeatures`; `LangVersion=latest` (and only on `net10.0`) is the only switch needed. On `net10.0` the generator emits internal `[Embedded]` polyfills for `UnionAttribute`/`IUnion`. On `net11.0` the BCL types are used and no polyfill is emitted. ## Turn the generator off `DisableUnionsSourceGenerator=true` (MSBuild property) disables generation and the polyfills. It is useful when inspecting the declared source in isolation; it is not a way to make a failing build pass. ## Where next - [Union Design](union-design/) - [Diagnostics](diagnostics/) - [Interop](interop/) - [Trimming and AOT](trimming-aot/) --- ## The problem The C# `union` keyword lowers to a struct with a single `object? Value`. That is compact, but every value-type case is boxed on entry, and matching a case goes through `Value` and boxes again. The language reference offers two escapes: **custom union types** and the **non-boxing access pattern**. This package generates that shape. A custom union is any class or struct carrying `System.Runtime.CompilerServices.UnionAttribute` and following the language's union pattern. The compiler recognises it by metadata name, not by the assembly that defines it, so an internal polyfill in the consumer's own compilation is enough on .NET 10. ## The compiler contract (verified, not assumed) The following were read from the C# 15 union proposal and the compiler's own metadata, then proven by the experiments kept in `src/tests/SourceGenerator.UnitTests/CompilerBehaviourTests.cs` (1–6) and `GeneratedUnionRuntimeTests.cs` (7). | Fact | Evidence | | --- | --- | | `UnionAttribute` exists on `net11.0` with a parameterless constructor and `[AttributeUsage(Class \| Struct, AllowMultiple = false)]` | `Microsoft.NETCore.App.Ref/11.0.0/ref/net11.0/System.Runtime.dll` | | `IUnion` has exactly one member, `object? Value { get; }` | same metadata | | Case types are the parameter types of public single-parameter constructors (by value or `in`) | proposal | | With a nested `IUnionMembers` provider, union members are found **only** on the provider | proposal | | A nullable value-type case is normalised to its underlying type (`int?` → case `int`) | proposal (resolved) | | The compiler does not synthesise `UnionAttribute`/`IUnion` | proposal (resolved) | | An implicit user-defined conversion shadows a union conversion; only one user-defined conversion may participate in a sequence | proposal | | `Value` is mandatory and boxes a value-type case when read | proposal + experiment 7 | ## The Phase 0 experiments All seven are permanent tests. Their results are recorded here so a future change cannot silently switch the design. | # | Experiment | Result | | --- | --- | --- | | 1 | A `[Union]` emitted by the generator on the author's partial struct produces implicit conversions and exhaustive switches | **Passes.** The attribute is emitted in the same compilation as the user's code, and the compiler applies union semantics. | | 2 | Source `[Union]` + generated constructors | **Passes.** This is the safe fallback if attribute emission ever regresses; the code fix for `UNI0007` supports it. | | 3 | `static partial` factory signatures inside the union | **Passes.** The generated implementation part binds before the caller needs it. | | 4 | A nested `IUnionMembers` provider whose `Create` bodies come from the source | **Passes, with the documented fallback.** A generator cannot add bodies to a source-declared interface, so the provider owns one-line `Create` bodies that delegate to the generated `CreateCaseN` methods. | | 5 | `ITypeSymbol.IsUnion` for a generated union | **True.** This is why the analyzer and the `PDS0006` suppressor work; Results reads the source case list (see [Interop](../interop/)). | | 6 | Internal polyfills on `net10.0`, no duplicate on `net11.0` | **Passes.** On `net10.0` the compiler recognises an internal `UnionAttribute` defined in the compilation. On `net11.0` no polyfill is emitted and the BCL type is used. | | 7 | No boxing in `is`/`switch`/`TryGetValue`, boxing in `Value` | **Passes.** Measured with `GC.GetAllocatedBytesForCurrentThread()`: 0 bytes for `is`, `switch` and `TryGetValue`, 24 bytes for `Value`. | ## The polyfill decision The brief asked for the polyfills from `RegisterPostInitializationOutput`. That method cannot inspect the compilation, so it would emit a second `UnionAttribute` on `net11.0` unless it were guarded by a preprocessor symbol — and a custom compiler host that omits `NET11_0_OR_GREATER` would then get `CS0436`. The shipped implementation instead emits the polyfills from a **normal source output** gated on `compilation.GetTypeByMetadataName("System.Runtime.CompilerServices.UnionAttribute") is null`, with at least one opted-in union and the generator enabled. That is precise: - `net10.0` gets internal `[Embedded]`/`[CompilerGenerated]` `UnionAttribute` and `IUnion`; - `net11.0` gets neither, so a duplicate is impossible; - a compilation that never applies `[GenerateUnion]` gets no polyfills at all. The deviation and its reason are deliberate, and this document is the record. ## Emitted shape For `[GenerateUnion(typeof(int), typeof(string))] public readonly partial struct IntOrString`, the generator emits (abridged): ```csharp [global::System.Runtime.CompilerServices.Union] public readonly partial struct IntOrString : global::System.Runtime.CompilerServices.IUnion { readonly int _case0; readonly string? _case1; readonly byte _tag; public IntOrString(int value) { _case0 = value; _case1 = default; _tag = 1; } public IntOrString(string? value) { if (value is null) return; _case0 = default; _case1 = value; _tag = 2; } public object? Value => _tag switch { 1 => _case0, 2 => _case1, _ => null }; public bool HasValue => _tag switch { 1 => true, 2 => _case1 is not null, _ => false }; public bool TryGetValue(out int value) { value = _case0; return _tag == 1; } public bool TryGetValue(out string value) { value = _case1!; return _tag == 2 && _case1 is not null; } } ``` Design points: - **Tag sizing is automatic**: `byte` up to 255 cases, then `ushort`; beyond `ushort.MaxValue` is `UNI0006`. - **`HasValue` is `Value is not null`**, per active case. A non-nullable value-type case is never null once its tag is set; a reference or nullable case is tested. - **`TryGetValue` returns true only for a non-null active case.** A nullable value-type case is stored as its underlying value, so a null constructor argument leaves `Tag == 0`. - **Reference-type parameters are nullable** (`string?`), so a consumer can pass `null` without a warning and get the empty union the language requires. - **No equality, `ToString`, `Deconstruct`, `Match` or `Switch`** for generated unions in v1. A union is matched by its active case; `CA1815` is expected and `PDS0006` answers it. A `record` union gets the compiler's record equality over the tag and fields. - **Surface C is `CA1034`-, `CA1515`- and `CA1000`-clean.** The provider must be a public nested interface, so the package's `UNI0000`, `UNI9001` and `UNI9002` suppression descriptors suppress the nesting, visibility and static-member-on-generic-type warnings for an `IUnionMembers` provider inside a union (for `CA1000`, on its static `Create` members); a differently named nested type, or one outside a union, keeps them. - **Accessibility is never widened.** The partial declaration repeats the union's accessibility, and every case type must be at least as accessible as the union because a public creation member cannot expose a less accessible parameter type. An internal union can therefore use internal case types; a public union needs public case types. - **Fully-qualified literal type names** are emitted for case types, so type parameters, arrays and nullable types render exactly without relying on a `TypeReference` identity that a type parameter does not have. ## Case resolution Cases come from the first source present, in this order: 1. the `[GenerateUnion]` `typeof` list; 2. the union's own partial static factory signatures; 3. a nested `IUnionMembers` provider; 4. existing public single-parameter constructors. A second, differing non-constructor source is `UNI0014`; existing constructors that differ are `UNI0012`. When an existing constructor set agrees with a declared list, the author owns the creation members and the generator emits only the attribute and, where it can, the access members — it never duplicates constructors. ## Why not the `union` keyword `union` declarations are useful and compact, but they always box. This package exists for the cases where that matters. It never emits a `union` declaration. --- Prefix: `UNI`. The analyzer reports every per-target rule; the generator applies the same findings to decide whether it can emit and reports only `UNI9000`. A rule is reported once. | ID | Severity | Meaning | | --- | --- | --- | | `UNI0001` | Error | The union declaration is not `partial` | | `UNI0002` | Error | Unsupported declaration kind (interface, enum, static/abstract class, `ref struct`) | | `UNI0003` | Error | No case list was found | | `UNI0004` | Error | Duplicate case type | | `UNI0005` | Error | A case type is less accessible than the union | | `UNI0006` | Error | More cases than a `ushort` tag can address | | `UNI0007` | Error | `[Union]` is required but missing (fallback path only) | | `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 uses a `typeof` case list | | `UNI0012` | Error | Existing constructors disagree with the declared case list | | `UNI0013` | Error | A containing type cannot be named from generated code | | `UNI0014` | Error | Two case lists are 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 | Every error blocks its union: the generated constructors, storage and access members are one coherent shape, so there is no partial output. `UNI0015` is the only warning and does not block. `UNI0000`, `UNI9001` and `UNI9002` are **suppression descriptors**, not reported rules. `UNI0000` suppresses `CA1034` (nested types should not be visible) and `UNI9001` suppresses `CA1515` (public types should be internal) on a union's public nested `IUnionMembers` provider, which the C# union contract requires. `UNI9002` suppresses `CA1000` (do not declare static members on generic types) on that provider's static `Create` members, which are the contract's creation members and are necessarily generic when the union is. Add any of these ids to `NoWarn` to turn that suppression off; a consumer should not need to. ## Details ### `UNI0001` — not `partial` The generator adds a second partial declaration. Add `partial`; the package's code fix does it for you. ### `UNI0002` — unsupported declaration kind The union pattern applies to classes and structs. Interfaces, enums, delegates, static classes, abstract classes and `ref struct`s cannot be generated as unions. ### `UNI0003` — no cases Declare the cases with a `typeof` list, partial factories, a nested `IUnionMembers` provider, or existing public single-parameter constructors. ### `UNI0004` — duplicate case Each case type appears once. A nullable value type and its underlying type are the same case (`int?` and `int`). ### `UNI0005` — case type less accessible than the union A case type must be at least as accessible as the union. A public union's creation members are public API, so its case types must be public all the way up their containers. An internal union's creation members are effectively internal, so an internal case type is enough. The code fix adds `public` to the case type and its containing types. ### `UNI0006` — too many cases The tag is sized automatically (`byte`, then `ushort`). This rule is only reachable beyond 65,535 cases; split the union. ### `UNI0007` — `[Union]` required The shipped generator emits the attribute itself, so this rule is not reported by default. It exists for the documented fallback path; the code fix adds the attribute fully qualified. ### `UNI0008` — ambiguous creation Two case types with an implicit conversion between them make a value convertible to both. Wrap one in a distinct record (`record struct Count(int Value)`) so the conversion sets are disjoint. ### `UNI0009` — unsupported case type Pointers, function pointers, `ref struct`s, `void`, static classes, `dynamic` and open generic definitions cannot be stored in a union field or exposed by a public constructor. ### `UNI0010` — unusable nullable case A nullable value-type case is exposed through its underlying type, and that type must be usable. This is a defensive rule: a symbol the compiler accepts today but cannot store is reported instead of emitting malformed code. ### `UNI0011` — generic union with a `typeof` list `typeof(T)` is not a legal attribute argument (`CS0416`). Use partial factory signatures or a nested provider. ### `UNI0012` — constructors disagree A hand-written union owns its creation members. When its public constructors differ from a declared case list, the two contradict each other. Keep one source of cases, or make them agree. ### `UNI0013` — containing type cannot be named Generated members are emitted as a second partial declaration nested inside the same containing types. Every containing type must be non-generic and `partial`. ### `UNI0014` — conflicting case lists More than one non-constructor source is present and they differ. Keep exactly one of the `typeof` list, partial factories, or provider. ### `UNI0015` — single case A union with one case adds a layer without adding a choice. Use the case type directly unless you need the union pattern for evolution. ### `UNI0016` — required member collides The generated shape owns `Value`, `HasValue` and `TryGetValue`. A union cannot be named `Value` (`CS0542`), and it cannot already declare one of those members unless it is hand-written with its own constructors. ### `UNI9000` — unexpected failure The generator catches its own failures so one malformed union cannot discard every other union's output (`CS8785`). The message names the union and the exception. Please report it with the declaration. ## The `[Union]` fallback path The generator emits `[System.Runtime.CompilerServices.Union]` in the same partial declaration as the members. The compiler recognises the attribute in generated trees, so the author never writes it. If a future compiler change ever prevents that, the generator can report `UNI0007` and the author adds the attribute themselves — which surface B and C already allow, because the attribute names no type parameters. --- A union is a plain type carrying `System.Runtime.CompilerServices.UnionAttribute`. Consumers see it through ordinary metadata, so interop depends on **what is visible in the user's source**: Roslyn generators never see each other's output. | Surface | Cases in source? | Visible to another generator? | | --- | --- | --- | | A — `typeof` list | Yes, on the attribute | Only the attribute list, not the constructors | | B — partial factories | Yes, as method signatures | Yes | | C — `IUnionMembers` provider | Yes, as provider methods | Yes | | D — `Union` | n/a | The type and its `[Union]` on `net11.0` | ## Purview.Results `[GenerateResult]` recognises a `[GenerateUnion]` target by metadata identity and reads the union's **source-visible** case list instead of relying on generated output. A generated union therefore works with Results without the author writing `[Union]` or constructors: - surface A (`typeof` list) and surface B (partial factory signatures) are read from the source; - surface C (`IUnionMembers` provider) is the portable source-visible route when another generator also needs the case list; - a `[GenerateUnion]` target with no source-visible case list is reported as `RSG1001` by Results. The Results tests use a stand-in `GenerateUnionAttribute` and source-visible conversions because `Purview.Unions` is not on a CI-safe package feed yet, so the real two-generator integration test remains a follow-up in `purview-dev/results`. Do not hand-write the constructors to satisfy Results: the union generator would then treat the union as hand-written and the declared and existing lists would disagree (`UNI0012`/`UNI0014`). A hand-written `[Union]` union whose cases live on a nested `IUnionMembers` provider — the shape the Results tests use — needs no `SuppressMessage` attribute for `CA1000`. The package's `UNI9002` suppression descriptor answers the rule on the provider's static `Create` members, which are generic whenever the union is. See [Diagnostics](../diagnostics/). ### Follow-up > `purview-dev/results`: add the two-generator integration test once `Purview.Unions` is on a CI-safe package > feed. > > `purview-dev/results`: once a `Purview.Unions` version carrying `UNI9002` is on the feed, bump > `PurviewUnionsVersion` in `Directory.Packages.props` and delete the `CA1000` `SuppressMessage` from > `ProvidedOption.IUnionMembers` in `src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs`. Verify by > raising `CA1000` to `warning` in a temporary project-local `.editorconfig` under that test project: the build > must report no `CA1000` for the provider's static `Create` members. ## Purview.Telemetry `Purview.Telemetry.SourceGenerator` 6.0.0 recognises a `[Purview.Unions.GenerateUnion]` target and reads the source-visible case list in the same order as this package: the `typeof` list, partial factory signatures, a nested `IUnionMembers` provider, then source-visible constructors. It emits the accessor against the `HasValue`/`TryGetValue` members the unions generator emits, so a surface A/B/C union is unwrapped to its case value without boxing the union. `just test-telemetry-integration` proves the real two-generator path from the packages: it packs `Purview.Unions`, references `Purview.Telemetry.SourceGenerator` 6.0.0 from `LOCAL_NUGET_FEED_PATH` (or `TELEMETRY_PRERELEASE_FEED`), and asserts the generated accessor calls `TryGetValue` for each case on `net10.0` and `net11.0`. - Surface A/B/C work; surface C remains the portable source-visible route for another generator that does not read the `[GenerateUnion]` list. - A `[GenerateUnion]` target with no source-visible case list is not treated as a union by Telemetry; the unions generator reports `UNI0003`. - A telemetry parameter typed as `Purview.Unions.Union` is visible as a union on `net11.0`, where the package assembly carries `[Union]`. ## ZodSharp `ZodSharp.Unions.Union` and `NativeUnion` predate this package. `Purview.Unions.Union` has the same two-case shape and a stable `Purview.Unions.IUnion` (`Value` + `Tag`). Migrating ZodSharp is a follow-up; do not mix the two types in one public contract meanwhile. ### Follow-up > `purview-dev/zodsharp`: migrate `ZodSharp.Unions.NativeUnion`/`Union` to > `Purview.Unions.Union` and keep `ZodSharp.Unions.IUnion` as an alias or remove it in a major version. ## JSON, EF Core and ASP.NET Core Out of scope for v1: - **JSON**: serialise a union by converting it to a DTO at the boundary. A `JsonConverter` would have to choose a discriminator policy, which belongs in a separate package. - **EF Core**: a union is not a scalar; map its active case to columns in the owning type. - **ASP.NET Core**: map a union to `ProblemDetails` in the host, as `Purview.Results.AspNetCore` already does for result failures. ### Follow-ups > Add `Purview.Unions.Serialization` (source-generated `System.Text.Json` converter) and > `Purview.Unions.EntityFrameworkCore` only when a consumer needs them; keep the core package > dependency-free. ## The union shape is a compiler contract Anything that reflects over the emitted members must handle the shape the compiler specifies: a public `Value`, an optional `HasValue`, and optional `TryGetValue` overloads. Do not depend on the private field names (`_case0`, `_tag`) — they are an implementation detail of this generator. --- Everything `Purview.Unions` ships is trimming- and Native AOT-safe. ## The runtime The runtime package is dependency-free, `IsAotCompatible` and `SupportedPlatform=browser`. It contains: - `GenerateUnionAttribute` — an ordinary attribute with a `Type[]` payload; - `Purview.Unions.IUnion` — a read-only interface; - `Union` — typed fields, `TryGetValue`, `Match`/`Switch`, equality and `ToString`. `Union` uses `EqualityComparer.Default` and two static runtime metadata checks, `typeof(T).IsValueType` and `Nullable.GetUnderlyingType(typeof(T))`, to decide whether a value needs the null test. Neither is member reflection, dynamic code or `Type.GetType`; both are AOT-safe and evaluated once per closed generic. There is no `System.Reflection` member access, no `Activator`, no `dynamic` and no runtime type discovery. ## Generated unions A generated union is: - one typed field per case; - one public constructor per case; - a `switch` over a small integer tag for `Value` and `HasValue`; - one `TryGetValue` overload per case. No reflection, no serialisation hooks, no dynamic code. The same members the compiler uses for pattern matching are the only ones the generator emits. ## The .NET 10 polyfills On `net10.0` the generator emits internal `[Embedded]`/`[CompilerGenerated]` `System.Runtime.CompilerServices.UnionAttribute` and `IUnion` into the consumer's compilation. They are internal, marked embedded, and not part of the public surface; trimming removes them if unused. On `net11.0` no polyfill is emitted and the BCL types are used. ## Verifying a consumer The publisher gate packs the package to an isolated feed and builds and runs consumers that reference only the package, then leak-scans their output for compiler-host assemblies: ```bash just smoke-packed-consumers ``` For a trimmed or AOT application of your own: ```xml true true true ``` A union used as a type parameter, a field or a local is a value type with a fixed set of fields, so the trimmer has nothing to guess at. If you expose a union in a public API, the case types are still ordinary types and their own trimming annotations apply. ## Blazor WebAssembly The package's `SupportedPlatform` includes `browser` and it uses no browser-unsupported API. A Blazor WebAssembly client can use any surface, including a generated union; `Value` boxes only when read. ## What is deliberately absent - **No reflection-based enumeration of cases.** The case list is a compile-time concept; expose it yourself if a runtime consumer needs it. - **No JSON converter.** A converter would need a discriminator policy and a resolver; that belongs in a separate package. - **No `ToString`/`Deconstruct` on generated unions.** They would invite treating a union as a tuple; match the active case instead. --- The allocation claim is the reason this package exists: a value-type union case is never boxed by construction, `TryGetValue`, `is` or `switch`. The committed BenchmarkDotNet suite measures that claim, the generator that produces the shape, and the object-backed baseline the shape replaces. Results are produced by [`src/src/Benchmarks`](https://github.com/purview-dev/unions/blob/main/src/src/Benchmarks) and folded here for reference. Every benchmark batches 1,000 operations through `OperationsPerInvoke`, so a sub-nanosecond operation is measurable and the JIT cannot fold the loop body away; the reported mean and allocation are per operation. ## How to run ```bash # Full suite on .NET 10 just perf-tests # Full suite on .NET 11 just perf-tests-net11 # One suite (the `--` passes the filter to BenchmarkDotNet) just perf-tests -- --filter "*GeneratedUnionBenchmarks*" ``` Always use a Release build. BenchmarkDotNet writes `BenchmarkDotNet.Artifacts/` at the repository root (the working directory the recipe runs in); the Markdown reports are the source for the tables below. The suite pins BenchmarkDotNet `0.16.0-preview.2` because `0.15.8` does not recognise the .NET 11 runtime. ## What is measured | Suite | Surfaces | | --- | --- | | `GeneratedUnionBenchmarks` | A: `[GenerateUnion(typeof(int), typeof(string))]` — creation, `TryGetValue`, `HasValue`, `is`, `switch`, `Value` | | `HandWrittenUnionBenchmarks` | D: `Union` — creation, `TryGetValue`, `HasValue`, `Tag`, `Match`, `Value` | | `GenericUnionBenchmarks` | B and C: `Maybe` and `Outcome` | | `BoxedBaselineBenchmarks` | the single-`object` shape a `union` declaration lowers to | | `GeneratorBenchmarks` | `UnionsSourceGenerator` over 1, 10 and 100 declarations, cold and incremental | `Value` returns `object`, so a value-type case is boxed when it is read. That is inherent to the union contract and is measured deliberately; do not “fix” it. Everything else in the generated shape must report `0 B`. ## Environment - BenchmarkDotNet v0.16.0-preview.2, Windows 11 (10.0.26300.9818) - 13th Gen Intel Core i9-13900KF 3.00 GHz, 1 CPU, 32 logical / 24 physical cores; 63.77 GB total, 38.32 GB available - .NET SDK 11.0.100; .NET 10.0.15 (10.0.15, 10.0.1526.53008), X64 RyuJIT x86-64-v3 - `IterationCount=5 LaunchCount=1 WarmupCount=3` These are the recorded `net10.0` numbers. The suite also targets `net11.0`; run `just perf-tests-net11` to measure the current runtime. Numbers from one machine are indicative — use them for relative comparison, and re-run before quoting absolute values. ## Generated union (surface A) | Method | Mean | Error | StdDev | Gen0 | Allocated | |------------------- |----------:|----------:|----------:|-------:|----------:| | Create_Int | 0.2200 ns | 0.0182 ns | 0.0028 ns | - | - | | Create_String | 0.1914 ns | 0.0122 ns | 0.0032 ns | - | - | | TryGetValue_Int | 0.2670 ns | 0.1613 ns | 0.0419 ns | - | - | | TryGetValue_String | 0.3538 ns | 0.2877 ns | 0.0747 ns | - | - | | TryGetValue_Miss | 0.2575 ns | 0.0840 ns | 0.0218 ns | - | - | | HasValue_Int | 0.2377 ns | 0.0204 ns | 0.0053 ns | - | - | | Is_Int | 0.2158 ns | 0.0463 ns | 0.0120 ns | - | - | | Switch_Int | 0.1916 ns | 0.0069 ns | 0.0018 ns | - | - | | Switch_String | 0.3762 ns | 0.0209 ns | 0.0054 ns | - | - | | Value_Int | 1.7450 ns | 0.5377 ns | 0.1396 ns | 0.0013 | 24 B | Construction, both `TryGetValue` directions, the miss path, `HasValue`, `is` and both `switch` shapes allocate **0 B**. `Value` allocates 24 bytes: the box for the `int` case. ## Hand-written union (surface D) | Method | Mean | Error | StdDev | Gen0 | Allocated | |------------------- |----------:|----------:|----------:|-------:|----------:| | Create_Int | 0.2120 ns | 0.0043 ns | 0.0011 ns | - | - | | Create_String | 0.1817 ns | 0.0055 ns | 0.0008 ns | - | - | | TryGetValue_Int | 0.1843 ns | 0.0016 ns | 0.0004 ns | - | - | | TryGetValue_String | 0.1855 ns | 0.0040 ns | 0.0010 ns | - | - | | TryGetValue_Miss | 0.1830 ns | 0.0009 ns | 0.0001 ns | - | - | | HasValue_Int | 0.1837 ns | 0.0019 ns | 0.0003 ns | - | - | | Tag_Int | 0.1805 ns | 0.0008 ns | 0.0002 ns | - | - | | Match_Int | 0.4778 ns | 0.0054 ns | 0.0008 ns | - | - | | Value_Int | 1.4837 ns | 0.0154 ns | 0.0024 ns | 0.0013 | 24 B | `Union` has the same allocation profile as the generated shape: everything except `Value` is 0 B. ## Generic unions (surfaces B and C) | Method | Mean | Error | StdDev | Gen0 | Allocated | |-------------------------- |----------:|----------:|----------:|-------:|----------:| | Maybe_CreateSome | 0.1832 ns | 0.0072 ns | 0.0011 ns | - | - | | Maybe_CreateNone | 0.1899 ns | 0.0112 ns | 0.0029 ns | - | - | | Maybe_TryGetValue_Some | 0.2185 ns | 0.0035 ns | 0.0009 ns | - | - | | Maybe_TryGetValue_None | 0.1997 ns | 0.0034 ns | 0.0009 ns | - | - | | Outcome_CreateValue | 0.2244 ns | 0.0354 ns | 0.0092 ns | - | - | | Outcome_CreateError | 3.8637 ns | 0.0569 ns | 0.0148 ns | 0.0064 | 120 B | | Outcome_TryGetValue_Value | 0.1837 ns | 0.0043 ns | 0.0007 ns | - | - | | Outcome_TryGetValue_Error | 0.2717 ns | 0.0040 ns | 0.0006 ns | - | - | The generic surfaces behave like the non-generic ones. `Outcome_CreateError` allocates 120 bytes because the benchmark constructs a new `InvalidOperationException` for every operation; that is the exception, not the union. Reading the same error case through `Outcome_TryGetValue_Error` is 0 B. ## Object-backed baseline | Method | Mean | Error | StdDev | Ratio | Gen0 | Allocated | |------------ |----------:|----------:|----------:|------:|-------:|----------:| | Create_Int | 2.1729 ns | 0.9646 ns | 0.2505 ns | 1.00 | 0.0013 | 24 B | | Pattern_Int | 0.3319 ns | 0.0142 ns | 0.0022 ns | 0.15 | - | - | | Switch_Int | 0.3174 ns | 0.0323 ns | 0.0084 ns | 0.15 | - | - | This is the shape a `union` declaration lowers to: one `object` field, so storing a value-type case boxes it (24 B and a slower path), and matching an already-boxed value does not. The generated union and `Union` remove the boxing at entry. ## Generator | Method | UnionCount | Mean | Error | StdDev | Ratio | Gen0 | Gen1 | Allocated | |--------------- |----------- |-----------:|-----------:|----------:|------:|---------:|---------:|-----------:| | **ColdRun** | **1** | **668.4 μs** | **25.93 μs** | **6.73 μs** | **1.00** | **17.5781** | **4.8828** | **328.28 KB** | | IncrementalRun | 1 | 128.8 μs | 4.92 μs | 0.76 μs | 0.19 | 2.6855 | 0.4883 | 51.91 KB | | **ColdRun** | **10** | **897.1 μs** | **559.07 μs** | **145.19 μs** | **1.00** | **39.0625** | **8.7891** | **730.17 KB** | | IncrementalRun | 10 | 223.7 μs | 14.83 μs | 3.85 μs | 0.25 | 7.5684 | 0.4883 | 139.65 KB | | **ColdRun** | **100** | **2,347.0 μs** | **69.76 μs** | **10.80 μs** | **1.00** | **261.7188** | **148.4375** | **4835.03 KB** | | IncrementalRun | 100 | 1,244.5 μs | 71.05 μs | 18.45 μs | 0.53 | 58.5938 | 9.7656 | 1087.95 KB | `ColdRun` constructs a fresh driver for every operation, which is the first generation in an IDE or build. `IncrementalRun` reuses the driver and compilation after a warm run, so it measures the cached path. The incremental path is 19–53% of the cold path and 16–23% of its allocation; the remainder is Roslyn updating the compilation with the generated trees, not the generator re-discovering cases. ## Interpreting the results 1. **Mean** — average execution time. 2. **Error / StdDev** — noise and spread. 3. **Gen 0/1** — collections per 1000 operations. 4. **Allocated** — managed bytes per operation. Signs to investigate: - **An allocation on construction, `TryGetValue`, `is`, `switch` or `HasValue`** means the non-boxing access pattern regressed. - **An allocation through `Value`** is expected for a value-type case and is the documented cost of the contract. - **A growing gap between `ColdRun` and `IncrementalRun`** means an incremental pipeline stage stopped being value-equatable and is re-running on every keystroke. ## Regenerating the tables Run the suite, then copy the `*-report-github.md` tables into the sections above and update the environment header. The benchmark project targets `net10.0` and `net11.0`; the recipe defaults to `net10.0` so a recorded run is reproducible against the LTS line. --- This repository authors **agent skills** so AI agents working with `Purview.Unions` get the guidance automatically. They live at `skills//SKILL.md` and are installed from this git repository with the skills CLI — they are not shipped inside the NuGet package. A skill can keep task-shaped detail in a `references/` folder, which an agent loads only when it is needed. ## Skills published by this repository | Skill | Covers | | --- | --- | | `skills/purview-unions-quickstart` | install, compiler requirements, a first union and the no-generator fallback | | `skills/purview-unions-modelling` | choosing a declaration surface, shaping the cases, generics and nullable value types | | `skills/purview-unions-diagnostics` | every `UNI0001`–`UNI0016`/`UNI9000` finding and the fix for it | | `skills/purview-unions-interop` | Results, Telemetry, ZodSharp, JSON, trimming and Native AOT | ## When to load which skill - Installing the package or writing your first union → `purview-unions-quickstart` - Choosing between the `typeof` list, partial factories, an `IUnionMembers` provider or `Union` → `purview-unions-modelling` - A `UNI` diagnostic, a missing generated member, or a union conversion that will not compile → `purview-unions-diagnostics` - A union crossing into Results, Telemetry, ZodSharp, JSON, a trimmed application or Native AOT → `purview-unions-interop` ## Install ```bash npx skills add purview-dev/unions ``` The CLI clones this repository, so the skills always match the branch you install from. ## Upstream skills The Purview engineering playbook, `Purview.BuildSdk` and the source-generator framework publish their own skills (engineering conventions, SDK configuration and project layout, generator authoring and testing) through the same CLI. Those are owned by the repository that publishes them — changes belong there, not here. This repository installs them into the universal `.agents/skills/` folder only (`-a universal`) and commits that folder together with the CLI's `skills-lock.json`, so a clone has them without running anything. Nothing is installed into `.claude/` or any other per-agent folder, and this repository's own skills are never copied into `.agents/skills/` — two copies would drift apart. ## Related - [Getting Started](../) — install, declare and use a union. - [Union Design](../union-design/) — the compiler contract and the emitted shape. - [Release Flow](../release-flow/) — where the authored skill set is released from. --- Releases are driven by the shared [purview-dev/build](https://github.com/purview-dev/build) pipeline through the GitHub Actions workflows in `.github/workflows/`. This repository owns configuration through `.config/purview-build.json`, not pipeline source code. ## Versioning `package.json` is the authoritative release and package version; `Purview.BuildSdk` applies it to `Version` and `PackageVersion` for every project. Never diverge a project's version by hand. Read the current line with `just version` rather than from this page; a prerelease uses a `MAJOR.MINOR.PATCH-prerelease.N` suffix. ## Releasing a version A push to `main` publishes, so everything below has to be true **before** the merge. 1. **Give the release a dated changelog heading.** Move the entries under `## [Unreleased]` into a new `## [] - YYYY-MM-DD` heading before the merge, so the record names the version that shipped. `package.json` stays the authoritative version; the heading is the human-facing history. 2. **Move new diagnostics into a shipped block.** New or changed rules stay in `src/src/SourceGenerator/AnalyzerReleases.Unshipped.md` until that version ships, then move into a `## Release ` block in `AnalyzerReleases.Shipped.md`. A rule stays recorded under the release that *introduced* it, so earlier blocks are never rewritten. 3. **Bump the package-validation baseline when a stable exists.** There is deliberately no baseline at `0.1.0-prerelease.1`: package validation proves internal consistency until the first stable ships. When it does, set `PackageValidationBaselineVersion` to that release and bump it with every stable. 4. **Run `just pipeline-pack-validate`**, which covers restore, build, lint, tests, pack and pack validation in one pass, then `just smoke-packed-consumers` and `just verify-pack-rules` when packaging, the generator or a dependency version changed. ## Workflows | Workflow | Trigger | Gate | | --- | --- | --- | | `.github/workflows/pr.yml` | pull requests against `main` (opened, synchronize, reopened, ready_for_review) | `build`: shared `purview-build.yml` with `run-pack: true` and `validate-pack: true`; the test filter lives only in `.config/purview-build.json`. `skills`: verifies `.agents/skills` against `skills-lock.json` without installing anything. `packed-consumers`: packs to an isolated feed, runs a `net10.0` and a `net11.0` consumer against the package only, leak-scans their output and proves every `ForbiddenContent` rule rejects a package. | | `.github/workflows/release.yml` | push to `main` | the `purview-release` composite action with `release-mode: NuGet`, Trusted Publishing, and the GitHub Packages mirror controlled by repository variables. | Both workflows get their SDK from `global.json` through `purview-dev/build`, which reads `.sdk.version` and installs exactly that (`11.0.100`). The shared `purview-build.yml`/`purview-release` code does it, and the two hand-rolled PR jobs call `purview-dev/build/actions/dotnet-sdk@main` directly. Keep the pin in `global.json` alone: a workflow-level `dotnet-version` is ignored once `global.json` states one, and `actions/setup-dotnet`'s `global-json-file` input falls back to the LTS channel when it cannot install the pin, reports success, and leaves the first `dotnet` command to fail with "A compatible .NET SDK was not found". The `packed-consumers` job additionally installs the .NET 10 runtime the `net10.0` consumer executes on; `global.json` still selects the pinned SDK. `release.yml` runs the composite action rather than the reusable workflow because nuget.org validates the OIDC `job_workflow_ref` claim against the registered Trusted Publishing policy, and inside a called workflow that claim names the called repository. The release workflow packs, publishes to NuGet, and creates the `v` GitHub release only when that tag does not already exist. Do not create release tags or publish packages manually. ## `.config/purview-build.json` | Key | Value | | --- | --- | | `Build:Solution` | `src/Unions.slnx` | | `Build:TestRoot` | `src/tests` | | `Build:TestPatterns` | `*Tests.csproj` | | `Build:TestProjects` | `*Tests*` | | `Build:TestFilter` | `/*/*/*/*` | | `Build:RunPack` / `Build:ValidatePack` | schema default `true`; the PR workflow passes both explicitly | | `PackValidation:RequiredCompilerFlags` | `optimization=release`, asserted for every assembly that ships a PDB | | `PackValidation:RequireSymbolPackage` | `true` | | `PackValidation:RequiredContent` | the exhaustive per-package content declaration | | `PackValidation:ForbiddenContent` | compiler-host assemblies anywhere, and the generator outside `analyzers/` | | `Release:Mode` | `None` (publishing is enabled only by the release workflow) | `RequireExplicitContent` defaults to `true`, so `RequiredContent` is exhaustive: a package with no rule, or an entry matching none of its globs, fails validation. Update it whenever package content changes — a new asset, a removed PDB, a renamed analyzer. ## Packed shape that must not regress - `Purview.Unions` ships the runtime library as `lib//Purview.Unions.dll` plus its XML documentation. - The IL-merged source generator and the code-fix assembly ship under `analyzers/dotnet/cs/` only, never under `lib/`, `ref/` or `runtimes/`, where every application would load them. - `buildTransitive/Purview.Unions.props`, `README.md` and `purview-logo-light.png` ship with the package. - A symbol package ships alongside it. - A loose `Purview.SourceGeneratorFramework.dll` beside the merged analyzer is a merge failure; `ForbiddenContent` rejects it. ## Analyzer release tracking The generator ships `UNI0001`–`UNI0016` and `UNI9000`, so it maintains the Roslyn release-tracking files. The catalogue is declared in `src/src/SourceGenerator/AnalyzerReleases.Unshipped.md`, which the compiler's `RS2008` rule validates during the build. Suppression descriptors are not release-tracked. ## Commands ```bash just pipeline-pr # restore, build, lint, tests just pipeline-build # restore, build, lint (no tests, no release) just pipeline-pack-validate # restore, build, lint, tests, pack, validate contents just pipeline-release # pack, publish, GitHub release (NuGet mode) just pipeline-local-release # pack, publish to a local NuGet feed just smoke-packed-consumers # pack, then run net10.0 and net11.0 consumers of the package just test-telemetry-integration # pack, then prove the Purview.Telemetry 6.0.0 integration (local feed) just verify-pack-rules # prove every ForbiddenContent rule rejects a package just version # the version package.json declares ``` The pipeline recipes run `scripts/restore-dotnet-tools.ps1`, which installs the pinned `Purview.Build` from `.config/dotnet-tools.json`. ## Testing the packed package locally A `.nupkg` published under a **new** version always extracts fresh, so bump `package.json`, publish to the local feed, then scrub the consuming repository: ```bash just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/ ``` In the consuming repository, run `just scrub` then `just build`. Use the `LOCAL_NUGET_FEED_PATH` environment variable when the explicit switch is inconvenient. NuGet reuses an already-extracted package for the same id **and** version, so prefer a new version over republishing one.